├── .circleci └── config.yml ├── .clj-kondo ├── http-kit │ └── http-kit │ │ ├── config.edn │ │ └── httpkit │ │ └── with_channel.clj ├── nubank │ └── matcher-combinators │ │ └── config.edn └── rewrite-clj │ └── rewrite-clj │ └── config.edn ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc └── kubernetes-patch-strategies.md ├── project.clj ├── resources └── kubernetes_api │ └── swagger.json ├── src └── kubernetes_api │ ├── apply.clj │ ├── core.clj │ ├── extensions │ └── custom_resource_definition.clj │ ├── interceptors │ ├── auth.clj │ ├── auth │ │ └── ssl.clj │ ├── encoders.clj │ └── raise.clj │ ├── internals │ ├── client.clj │ └── martian.clj │ ├── listeners.clj │ ├── misc.clj │ └── swagger.clj └── test └── kubernetes_api ├── core_test.clj ├── extensions └── custom_resource_definition_test.clj ├── interceptors ├── auth_test.clj └── raise_test.clj ├── internals ├── client_test.clj └── martian_test.clj ├── resources └── test_swagger.json └── swagger_test.clj /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Clojure CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-clojure/ for more details 4 | 5 | version: 2 6 | defaults: &defaults 7 | working_directory: ~/repo 8 | docker: 9 | - image: circleci/clojure:lein-2.9.1 10 | environment: 11 | LEIN_ROOT: "true" 12 | JVM_OPTS: -Xmx3200m 13 | 14 | jobs: 15 | build: 16 | <<: *defaults 17 | steps: 18 | - checkout 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-{{ checksum "project.clj" }} 22 | - v1-dependencies- 23 | - run: lein deps 24 | - save_cache: 25 | paths: 26 | - ~/.m2 27 | key: v1-dependencies-{{ checksum "project.clj" }} 28 | - run: lein test 29 | lint: 30 | <<: *defaults 31 | steps: 32 | - checkout 33 | - restore_cache: 34 | keys: 35 | - v1-dependencies-{{ checksum "project.clj" }} 36 | - v1-dependencies- 37 | - run: lein lint 38 | workflows: 39 | version: 2 40 | build_and_test: 41 | jobs: 42 | - build 43 | - lint: 44 | requires: 45 | - build 46 | 47 | -------------------------------------------------------------------------------- /.clj-kondo/http-kit/http-kit/config.edn: -------------------------------------------------------------------------------- 1 | 2 | {:hooks 3 | {:analyze-call {org.httpkit.server/with-channel httpkit.with-channel/with-channel}}} 4 | -------------------------------------------------------------------------------- /.clj-kondo/http-kit/http-kit/httpkit/with_channel.clj: -------------------------------------------------------------------------------- 1 | (ns httpkit.with-channel 2 | (:require [clj-kondo.hooks-api :as api])) 3 | 4 | (defn with-channel [{node :node}] 5 | (let [[request channel & body] (rest (:children node))] 6 | (when-not (and request channel) (throw (ex-info "No request or channel provided" {}))) 7 | (when-not (api/token-node? channel) (throw (ex-info "Missing channel argument" {}))) 8 | (let [new-node 9 | (api/list-node 10 | (list* 11 | (api/token-node 'let) 12 | (api/vector-node [channel (api/vector-node [])]) 13 | request 14 | body))] 15 | 16 | {:node new-node}))) 17 | -------------------------------------------------------------------------------- /.clj-kondo/nubank/matcher-combinators/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:unresolved-symbol 3 | {:exclude [(cljs.test/is [match? thrown-match?]) 4 | (clojure.test/is [match? thrown-match?])]}}} 5 | -------------------------------------------------------------------------------- /.clj-kondo/rewrite-clj/rewrite-clj/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as 2 | {rewrite-clj.zip/subedit-> clojure.core/-> 3 | rewrite-clj.zip/subedit->> clojure.core/->> 4 | rewrite-clj.zip/edit-> clojure.core/-> 5 | rewrite-clj.zip/edit->> clojure.core/->>}} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | .hgignore 12 | .hg/ 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.0.0 4 | - Add support for base64 secrets 5 | - [BREAKING] Remove CRDs by default, adds new `:apis` field. 6 | 7 | If you are using k8s-api to interact with CRDs, you need to set the api group (and optionally the versioning explicitly). 8 | 9 | Example: 10 | ```clojure 11 | (k8s/client url 12 | {:token ... 13 | :apis [:my.crd.group/v1]}) 14 | ``` 15 | 16 | ## 0.4.0 17 | - Update unreleased extend-client function to use apiextensions/v1, since apiextensions/v1beta1 was deleted in newer versions of kubernetes 18 | - Refactor extend-client code to simplify it 19 | 20 | ## 0.3.0 21 | - Bump martian to version 0.1.26 to get fix about [parameters spec](https://github.com/oliyh/martian/pull/196) 22 | - Add openapi configuration to disable automatic discovery (see README.md) 23 | 24 | ## 0.2.1 25 | - Add support for JSON Patch, JSON Merge, Strategic Merge Patch and Server-Side 26 | Apply requests. 27 | - Change how raise interceptor works to avoid unnecessary logging 28 | 29 | ## 0.1.2 30 | - Fixes corner cases like `misc/logs` for Pods 31 | 32 | ## 0.1.0 33 | - Initial version 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kubernetes-api 2 | 3 | [![Clojars Project](https://img.shields.io/clojars/v/nubank/k8s-api.svg)](https://clojars.org/nubank/k8s-api) 4 | 5 | kubernetes-api is a Clojure library that acts as a kubernetes client 6 | 7 | ## Motivation 8 | 9 | We had a good experience with 10 | [cognitect-labs/aws-api](https://github.com/cognitect-labs/aws-api), and missed 11 | something like that for Kubernetes API. We had some client libraries that 12 | generated a lot of code, but it lacked discoverability and documentation. 13 | 14 | ### clojure.deps 15 | ```clojure 16 | {:deps {nubank/k8s-api {:mvn/version "1.0.0"}}} 17 | ``` 18 | 19 | ### Leiningen 20 | ```clojure 21 | [nubank/k8s-api "1.0.0"] 22 | ``` 23 | 24 | ```clojure 25 | ;; In your ns statement 26 | (ns my.ns 27 | (:require [kubernetes-api.core :as k8s])) 28 | ``` 29 | 30 | 31 | ## Usage 32 | ### Instantiate a client 33 | 34 | There're multiple options for authentication while instantiating a client. You 35 | can explicit set a token: 36 | ```clojure 37 | (def k8s (k8s/client "http://some.host" {:token "..."})) 38 | ``` 39 | 40 | Or a function that returns the token 41 | 42 | ```clojure 43 | (def k8s (k8s/client "http://some.host" {:token-fn (constantly "...")})) 44 | ``` 45 | 46 | You can also define client certificates 47 | ```clojure 48 | (def k8s (k8s/client "http://some.host" {:ca-cert "/some/path/ca-docker.crt" 49 | :client-cert "/some/path/client-cert.pem" 50 | :client-key "/some/path/client-java.key"})) 51 | ``` 52 | 53 | #### OpenAPI config 54 | 55 | ##### Discovery 56 | 57 | It's possible but NOT RECOMMENDED to disable the OpenAPI specification discovery. This will prevent requests to 58 | `/openapi/...` endpoints and use the specification from the resources folder. This specification has no guarantees in 59 | terms of versioning, so it will be outdated. 60 | ```clojure 61 | (def k8s (k8s/client "http://some.host" {:token "..." 62 | :openapi {:discovery :disabled}})) 63 | ``` 64 | 65 | ##### Filter paths from api 66 | 67 | You can filter the paths from the OpenAPI specification. This is useful when you want to use a specific version of the 68 | api, or when you want to use a specific group of resources. 69 | 70 | ```clojure 71 | (def k8s (k8s/client "http://some.host" {:token "..." 72 | :apis ["some.api/v1alpha1", "another.api"]})) 73 | ``` 74 | 75 | ### Discover 76 | You can list all operations with 77 | ```clojure 78 | (k8s/explore k8s) 79 | ``` 80 | 81 | or specify a specific entity 82 | ```clojure 83 | (k8s/explore k8s :Deployment) 84 | ;=> 85 | [:Deployment 86 | [:get "read the specified Deployment"] 87 | [:update "replace the specified Deployment"] 88 | [:delete "delete a Deployment"] 89 | [:patch "partially update the specified Deployment"] 90 | [:list "list or watch objects of kind Deployment"] 91 | [:create "create a Deployment"] 92 | [:deletecollection "delete collection of Deployment"] 93 | [:list-all "list or watch objects of kind Deployment"]] 94 | ``` 95 | 96 | get info on an operation 97 | ```clojure 98 | (k8s/info k8s {:kind :Deployment 99 | :action :create}) 100 | ;=> 101 | {:summary "create a Deployment", 102 | :parameters {:namespace java.lang.String, 103 | #schema.core.OptionalKey{:k :pretty} (maybe Str), 104 | #schema.core.OptionalKey{:k :dry-run} (maybe Str), 105 | #schema.core.OptionalKey{:k :field-manager} (maybe Str), 106 | :body ...}, 107 | :returns {200 {#schema.core.OptionalKey{:k :apiVersion} (maybe Str), 108 | #schema.core.OptionalKey{:k :kind} (maybe Str), 109 | #schema.core.OptionalKey{:k :metadata} ..., 110 | #schema.core.OptionalKey{:k :spec} ..., 111 | #schema.core.OptionalKey{:k :status} ...}, 112 | 201 {#schema.core.OptionalKey{:k :apiVersion} (maybe Str), 113 | #schema.core.OptionalKey{:k :kind} (maybe Str), 114 | #schema.core.OptionalKey{:k :metadata} ..., 115 | #schema.core.OptionalKey{:k :spec} ..., 116 | #schema.core.OptionalKey{:k :status} ...}, 117 | 202 {#schema.core.OptionalKey{:k :apiVersion} (maybe Str), 118 | #schema.core.OptionalKey{:k :kind} (maybe Str), 119 | #schema.core.OptionalKey{:k :metadata} ..., 120 | #schema.core.OptionalKey{:k :spec} ..., 121 | #schema.core.OptionalKey{:k :status} ...}, 122 | 401 Any}} 123 | ``` 124 | 125 | 126 | ### Invoke 127 | 128 | You can call an operation with 129 | ```clojure 130 | (k8s/invoke k8s {:kind :ConfigMap 131 | :action :create 132 | :request {:namespace "default" 133 | :body {:api-version "v1" 134 | :data {"foo" "bar"}}}}) 135 | ``` 136 | 137 | invoke it will return the body, with `:request` and `:response` in metadata 138 | ```clojure 139 | (meta (k8s/invoke ...)) 140 | ;=> 141 | {:request ... 142 | :response {:status ... 143 | :body ...}} 144 | ``` 145 | 146 | You can debug the request map with 147 | ```clojure 148 | (k8s/request k8s {:kind :ConfigMap 149 | :action :create 150 | :request {:namespace "default" 151 | :body {:api-version "v1" 152 | :data {"foo" "bar"}}}}) 153 | ``` 154 | 155 | If you want to read more about all different patch strategies Kubernetes offers, 156 | [check this document](doc/kubernetes-patch-strategies.md) 157 | -------------------------------------------------------------------------------- /doc/kubernetes-patch-strategies.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Patch Strategies 2 | 3 | The official Kubernetes documentation has great information about patches and 4 | their uses [here][k8s-doc]. This document is an overview to what to expect and 5 | how to use them. 6 | 7 | ## JSON Patch RFC-6902 8 | This is the simpler patch Kubernetes supports. You have the full description 9 | [here][rfc6902]. The `op` field can be `add`, `remove`, `replace`, `move`, 10 | `copy` and `test`. 11 | 12 | The `path` is a slash-separated field list to get to the value you desire to 13 | patch, described by the [JSON Pointer RFC-6901][rfc6901]. You can use `~0` and 14 | `~1` to describe fields with `~` and `/` characters, respectively. 15 | 16 | The `value` field is usually the value you want to add in our manifest. 17 | 18 | In the Kubernetes API, this is done by setting Content-Type header to 19 | `application/json-patch+json`, and in this library is defined by the action 20 | `:patch/json`. 21 | 22 | This style of patching is quite good when you only want to change a simple field, 23 | like changing `replicas` in a Deployment or adding a specific label. 24 | 25 | ```clojure 26 | (k8s/invoke c {:kind :Deployment 27 | :action :patch/json 28 | :request {:name "nginx-deployment" 29 | :namespace "default" 30 | :body [{:op "add" 31 | :path "/spec/replicas" 32 | :value 2}]}}) 33 | 34 | ``` 35 | ## JSON Merge Patch RFC-7286 36 | This patch can be used for the same purpose of the JSON Patch, but has some 37 | conceptual differences. You have the full description [here][rfc7386]. 38 | 39 | The body of the request is similar to a diff, meaning that you only need to 40 | describe what is going to change. This strategy tries to move away from the JSON 41 | Patch imperative approach by allowing you to describe a data structure similar 42 | to the one you're patching. 43 | 44 | In the Kubernetes API, this is done by setting Content-Type header to 45 | `application/merge-patch+json`, and in this library is defined by the action 46 | `:patch/json-merge`. 47 | 48 | 49 | ```clojure 50 | (k8s/invoke c {:kind :Deployment 51 | :action :patch/json-merge 52 | :request {:name "nginx-deployment" 53 | :namespace "default" 54 | :body {:kind "Deployment" 55 | :api-version "apps/v1" 56 | :spec {:template {:spec {:containers [{:name "sidecar" 57 | :image "sidecar:v2" 58 | :ports [{:container-port 8080}]} 59 | {:name "nginx" 60 | :image "nginx:1.14.2" 61 | :ports [{:container-port 80}]}]}}}}}}) 62 | ``` 63 | 64 | Note that you don't have to explicit set all fields from the Deployment, only 65 | those that are relevant for your patch. Also note that for lists, it will 66 | substitute the whole list, so you need to be careful. 67 | 68 | ## Strategic Merge Patch 69 | 70 | Since neither of the other patch strategies have a good way to deal with lists, 71 | Kubernetes decided to introduce a new "strategic" merge patch. It is "strategic" 72 | meaning that it knows enough about the manifest's structure to make decisions 73 | about how to merge them. Read more [here][notes-on-the-strategic-merge-patch]. 74 | 75 | Note that this strategy is not available for Custom Resource Definitions yet. 76 | 77 | ```clojure 78 | (k8s/invoke c {:kind :Deployment 79 | :action :patch/strategic 80 | :request {:name "nginx-deployment" 81 | :namespace "default" 82 | :body {:kind "Deployment" 83 | :spec {:template {:spec {:containers [{:name "nginx" 84 | :image "nginx:1.14.1" 85 | :ports [{:name "metrics" 86 | :container-port 80}]}]}}}}}}) 87 | ``` 88 | 89 | Note that this strategy allow you to customize containers inside a Deployment, 90 | without having to worry about the order they are defined or the existence of 91 | other containers. 92 | 93 | In the Kubernetes API, this is done by setting Content-Type header to 94 | `application/strategic-merge-patch+json`, and in this library is defined by the 95 | action `:patch/strategic`. 96 | 97 | ## Server-Side Apply 98 | 99 | This strategy is similar in the way you describe the change, but adds a layer of 100 | ownership of fields, automatically adding values to `metadata.managedFields` 101 | field. This allows you to identify conflicting changes from multiple sources. 102 | Read more [here][server-side-apply] 103 | 104 | In the Kubernetes API, this is done by setting Content-Type header to 105 | `application/apply-patch+yaml`, and in this library is defined by the 106 | action `:apply/server`. 107 | 108 | [k8s-doc]: https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/ 109 | [rfc6902]: https://www.rfc-editor.org/rfc/rfc6902 110 | [rfc6901]: https://www.rfc-editor.org/rfc/rfc6901 111 | [rfc7386]: https://www.rfc-editor.org/rfc/rfc7386 112 | [notes-on-the-strategic-merge-patch]: https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/#notes-on-the-strategic-merge-patch 113 | 114 | [server-side-apply]: https://kubernetes.io/docs/reference/using-api/server-side-apply/ 115 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject nubank/k8s-api "1.1.0-SNAPSHOT" 2 | :description "A library to talk with kubernetes api" 3 | :url "https://github.com/nubank/k8s-api" 4 | :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" 5 | :url "https://www.eclipse.org/legal/epl-2.0/"} 6 | :plugins [[lein-cljfmt "0.5.7"] 7 | [lein-kibit "0.1.6"] 8 | [lein-nsorg "0.2.0"]] 9 | :cljfmt {:indents {providing [[:inner 0]]}} 10 | :dependencies [[org.clojure/clojure "1.11.0"] 11 | [com.github.oliyh/martian "0.1.26"] 12 | [com.github.oliyh/martian-httpkit "0.1.26"] 13 | [less-awful-ssl "1.0.6"] 14 | [http-kit/http-kit "2.8.0-alpha3"]] 15 | :main ^:skip-aot kubernetes-api.core 16 | :resource-paths ["resources"] 17 | :target-path "target/%s" 18 | :aliases {"lint" ["do" ["cljfmt" "check"] ["nsorg"] ["kibit"]] 19 | "lint-fix" ["do" ["cljfmt" "fix"] ["nsorg" "--replace"] ["kibit" "--replace"]]} 20 | :profiles {:uberjar {:aot :all} 21 | :dev {:resource-paths ["test/kubernetes_api/resources"] 22 | :dependencies [[nubank/matcher-combinators "3.8.5"] 23 | [nubank/mockfn "0.7.0"]]}}) 24 | -------------------------------------------------------------------------------- /src/kubernetes_api/apply.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.apply 2 | (:require [kubernetes-api.core :as k8s] 3 | [yaml.core :as yaml])) 4 | 5 | (defn apply-file [client filepath]) 6 | 7 | -------------------------------------------------------------------------------- /src/kubernetes_api/core.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.core 2 | (:require [kubernetes-api.extensions.custom-resource-definition :as crd] 3 | [kubernetes-api.interceptors.auth :as interceptors.auth] 4 | [kubernetes-api.interceptors.encoders :as interceptors.encoders] 5 | [kubernetes-api.interceptors.raise :as interceptors.raise] 6 | [kubernetes-api.internals.client :as internals.client] 7 | [kubernetes-api.internals.martian :as internals.martian] 8 | [kubernetes-api.misc :as misc] 9 | [kubernetes-api.swagger :as swagger] 10 | [martian.core :as martian] 11 | [martian.interceptors :as interceptors] 12 | [martian.httpkit :as martian-httpkit] 13 | martian.swagger)) 14 | 15 | (def default-apis 16 | "Default API Groups used by Kubernetes. 17 | We don't specify versions as we intend to be as future-proof as possible. 18 | https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#api-groups" 19 | [:admissionregistration.k8s.io 20 | :apiextensions.k8s.io 21 | :apiregistration.k8s.io 22 | :apps 23 | :authentication.k8s.io 24 | :authorization.k8s.io 25 | :autoscaling 26 | :batch 27 | :certificates.k8s.io 28 | :coordination.k8s.io 29 | :core 30 | :discovery.k8s.io 31 | :events.k8s.io 32 | :flowcontrol.apiserver.k8s.io 33 | :internal.apiserver.k8s.io 34 | :networking.k8s.io 35 | :node.k8s.io 36 | :policy 37 | :rbac.authorization.k8s.io 38 | :resource.k8s.io 39 | :scheduling.k8s.io 40 | :storage.k8s.io]) 41 | 42 | (def defaults 43 | {:apis default-apis}) 44 | 45 | (defn client 46 | "Creates a Kubernetes Client compliant with martian api and its helpers 47 | 48 | host - a string url to the kubernetes cluster 49 | 50 | Options: 51 | 52 | [Authentication] 53 | :basic-auth - a map with plain text username/password 54 | :token - oauth token string without Bearer prefix 55 | :token-fn - a single-argument function that receives this opts and returns a 56 | token 57 | :client-cert/:ca-cert/:client-key - string filepath indicating certificates 58 | and key files to configure client cert. 59 | :certificate-authority-data - a base64 encoded string with the certificate 60 | authority data 61 | :client-certificate-data - a base64 encoded string with the client certificate 62 | alternative to :client-cert 63 | :client-key-data - a base64 encoded string with the client key alternative 64 | to :client-key 65 | :insecure? - ignore self-signed server certificates 66 | 67 | [Custom] 68 | :interceptors - additional interceptors to the martian's client 69 | :apis - a list of api groups and optionally versions. 70 | Defaults to kubernetes-api.core/default-apis 71 | 72 | [OpenAPI] 73 | :openapi/:discovery - :disabled to avoid fetching openapi schema from k8s 74 | 75 | Example 1: 76 | (client \"https://kubernetes.docker.internal:6443\" 77 | {:basic-auth {:username \"admin\" 78 | :password \"1234\"}}) 79 | Example 2: 80 | (client \"https://kubernetes.docker.internal:6443\" 81 | {:basic-auth {:username \"admin\" 82 | :password \"1234\"} 83 | :apis [:some.api/v1alpha1, :another.api/v1beta1]})" 84 | [host opts] 85 | (let [opts (merge defaults opts) 86 | interceptors (concat [(interceptors.raise/new opts) 87 | (interceptors.auth/new opts)] 88 | (:interceptors opts) 89 | martian/default-interceptors 90 | [(interceptors.encoders/new) 91 | interceptors/default-coerce-response 92 | martian-httpkit/perform-request]) 93 | k8s (internals.client/transform 94 | (martian/bootstrap-swagger host 95 | (or (swagger/from-api host opts) 96 | (swagger/read opts)) 97 | {:interceptors interceptors}))] 98 | (assoc k8s 99 | ::api-group-list (internals.martian/response-for k8s :GetApiVersions) 100 | ::core-api-versions (internals.martian/response-for k8s :GetCoreApiVersions)))) 101 | 102 | (defn invoke 103 | "Invoke a action on kubernetes api 104 | 105 | Parameters: 106 | :kind - a keyword identifing a kubernetes entity 107 | :action - each entity can have different subset of action. Examples: 108 | :create :update :patch :list :get :delete :deletecollection 109 | :request - to check what is this use kubernetes-api.core/info function 110 | Example: 111 | (invoke k8s {:kind :Deployment 112 | :action :create 113 | :request {:namespace \"default\" 114 | :body {:apiVersion \"v1\", ...}})" 115 | [k8s {:keys [request] :as params}] 116 | (if-let [action (internals.client/find-preferred-route k8s (dissoc params :request))] 117 | (internals.martian/response-for k8s action (or request {})) 118 | (throw (ex-info "Could not find action" {:search (dissoc params :request)})))) 119 | 120 | (defn extend-client 121 | "Extend a Kubernetes Client to support CustomResourceDefinitions 122 | 123 | Example: 124 | (extend-client k8s {:api \"tekton.dev\" :version \"v1alpha1\"})" 125 | [k8s {:keys [api version] :as extension-api}] 126 | (let [api-resources (internals.martian/response-for k8s :GetArbitraryApiResources 127 | {:api api 128 | :version version}) 129 | crds (internals.martian/response-for k8s :ListApiextensionsV1CustomResourceDefinition)] 130 | (internals.client/pascal-case-routes 131 | (update k8s 132 | :handlers #(concat % (martian.swagger/swagger->handlers 133 | (crd/swagger-from extension-api api-resources crds))))))) 134 | 135 | (defn explore 136 | "Return a data structure with all actions performable on Kubernetes API, 137 | organized per kind and per action 138 | 139 | Examples: 140 | (explore k8s) 141 | => [[:Deployment 142 | [:get \"some description\"] 143 | ...] 144 | [:Service 145 | [:create \"other description\"] 146 | ...]] 147 | (explore k8s :Deployment) 148 | => [:Deployment 149 | [:create \"description\"] 150 | ...]" 151 | ([{:keys [handlers] :as k8s}] 152 | (->> (filter (partial internals.client/preffered-version? k8s) handlers) 153 | (group-by internals.client/kind) 154 | (map (fn [[kind handlers]] 155 | (vec (cons (keyword kind) 156 | (mapv (juxt internals.client/action :summary) handlers))))) 157 | (sort-by (comp str first)) 158 | vec)) 159 | ([k8s kind] 160 | (->> (explore k8s) 161 | (misc/find-first #(= kind (first %))) 162 | vec))) 163 | 164 | (defn request 165 | "Returns the map that is passed to org.httpkit.client/request function. Used 166 | mostly for debugging. For customizing this, use the :interceptors option 167 | while creating an client" 168 | [k8s {:keys [request] :as params}] 169 | (if-let [action (internals.client/find-preferred-route k8s (dissoc params :request))] 170 | (martian/request-for k8s action (or request {})) 171 | (throw (ex-info "Could not find action" {:search (dissoc params :request)})))) 172 | 173 | (defn info 174 | "Returns everything on a specific action, including request and response 175 | schemas" 176 | [k8s params] 177 | (martian/explore k8s (internals.client/find-preferred-route k8s (dissoc params :request)))) 178 | -------------------------------------------------------------------------------- /src/kubernetes_api/extensions/custom_resource_definition.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.extensions.custom-resource-definition 2 | (:require [camel-snake-kebab.core :as csk] 3 | [clojure.string :as string] 4 | [kubernetes-api.misc :as misc])) 5 | 6 | (defn new-route-name 7 | [verb group version scope kind {:keys [all-namespaces]}] 8 | (letfn [(prefix [k8s-verb] 9 | (case k8s-verb 10 | "update" "replace" 11 | "get" "read" 12 | "deletecollection" "delete" 13 | k8s-verb)) 14 | (suffix [k8s-verb] 15 | (case k8s-verb 16 | "deletecollection" "collection" 17 | nil))] 18 | (csk/->PascalCase (string/join "_" (->> [(prefix verb) 19 | group 20 | version 21 | (suffix verb) 22 | (when-not all-namespaces scope) 23 | (csk/->snake_case kind) 24 | (when all-namespaces :for_all_namespaces)] 25 | (remove nil?) 26 | (map name))) 27 | :separator #"[_\.]"))) 28 | 29 | (def k8s-verb->http-verb 30 | {"delete" "delete" 31 | "deletecollection" "delete" 32 | "get" "get" 33 | "list" "get" 34 | "patch" "patch" 35 | "create" "post" 36 | "update" "put" 37 | "watch" "get"}) 38 | 39 | (def k8s-verb->summary-template 40 | {"delete" "delete a %s" 41 | "deletecollection" "delete collection of %s" 42 | "get" "read the specified %s" 43 | "list" "list of watch objects of kind %s" 44 | "patch" "partially update the specified %s" 45 | "create" "create a %s" 46 | "update" "replace the specified %s" 47 | "watch" "deprecated: use the 'watch' parameter with a list operation instead"}) 48 | 49 | (defn method [k8s-verb {:keys [api version]} {{:keys [scope versions names]} :spec :as _crd} opts] 50 | (let [content-types ["application/json" 51 | "application/yaml" 52 | "application/vnd.kubernetes.protobuf"] 53 | crd-version (misc/find-first #(= (:name %) version) versions) 54 | kind (:kind names)] 55 | {(keyword (k8s-verb->http-verb k8s-verb)) 56 | (misc/assoc-some {:summary (format (k8s-verb->summary-template k8s-verb) kind) 57 | :operationId (new-route-name k8s-verb api version scope kind opts) 58 | :consumes content-types 59 | :produces content-types 60 | :responses {"200" (misc/assoc-some 61 | {:description "OK"} 62 | :schema (-> crd-version :schema :openAPIV3Schema)) 63 | "401" {:description "Unauthorized"}} 64 | :x-kubernetes-action k8s-verb 65 | :x-kubernetes-group-version-kind {:group api 66 | :version version 67 | :kind kind}} 68 | :parameters (when (#{"create" "update"} k8s-verb) 69 | [{:in "body" 70 | :name "body" 71 | :required true 72 | :schema {:type "object"}}]))})) 73 | 74 | (defn top-level [extension-api] 75 | (str "/apis/" (:api extension-api) "/" (:version extension-api))) 76 | 77 | (defn top-resource [extension-api resource-name] 78 | (str (top-level extension-api) "/" resource-name)) 79 | 80 | (defn namespaced-route [extension-api resource-name] 81 | (str (top-level extension-api) "/namespaces/{namespace}/" resource-name)) 82 | 83 | (defn named-route [extension-api resource-name] 84 | (str (namespaced-route extension-api resource-name) "/{name}")) 85 | 86 | (defn routes [k8s-verb extension-api {resource-name :name :as resource} crd] 87 | (cond 88 | (#{:get :delete :patch :update} (keyword k8s-verb)) 89 | {(named-route extension-api resource-name) (method k8s-verb extension-api crd {})} 90 | 91 | (#{:create :deletecollection} (keyword k8s-verb)) 92 | {(namespaced-route extension-api resource-name) (method k8s-verb extension-api crd {})} 93 | 94 | (= :watch (keyword k8s-verb)) 95 | {} ; TODO: Fix Watch requests 96 | 97 | (= :list (keyword k8s-verb)) 98 | {(top-resource extension-api resource-name) (method k8s-verb extension-api crd {:all-namespaces true}) 99 | (namespaced-route extension-api resource-name) (method k8s-verb extension-api crd {})})) 100 | 101 | (defn add-path-params [extension-api {resource-name :name} paths] 102 | (into {} 103 | (map (fn [[path methods]] 104 | [path (misc/assoc-some methods 105 | :parameters (cond 106 | (string/starts-with? (name path) (named-route extension-api resource-name)) 107 | [{:in "path" 108 | :name "name" 109 | :required true 110 | :schema {:type "string"}} 111 | {:in "path" 112 | :name "namespace" 113 | :required true 114 | :schema {:type "string"}}] 115 | (string/starts-with? (name path) (namespaced-route extension-api resource-name)) 116 | [{:in "path" 117 | :name "namespace" 118 | :required true 119 | :schema {:type "string"}}] 120 | :else nil))]) paths))) 121 | 122 | (defn single-resource-swagger [extension-api {:keys [verbs] :as resource} crd] 123 | (->> (mapcat #(routes % extension-api resource crd) verbs) 124 | (group-by first) 125 | (misc/map-vals (fn [x] (into {} (map second x)))) 126 | (into {}) 127 | (add-path-params extension-api resource))) 128 | 129 | (defn status-subresource? [resource] 130 | (string/includes? (:name resource) "/status")) 131 | 132 | (defn scale-subresource? [resource] 133 | (string/includes? (:name resource) "/scale")) 134 | 135 | (defn subresource? [resource] 136 | (or (status-subresource? resource) 137 | (scale-subresource? resource))) 138 | 139 | (defn top-level-resources [resources] 140 | (let [top-levels (remove subresource? resources) 141 | subresources (filter subresource? resources)] 142 | (mapv (fn [resource] 143 | (misc/assoc-some resource 144 | :status? (some #(and (status-subresource? %) 145 | (string/starts-with? (:name %) (:name resource))) 146 | subresources) 147 | :scale? (some #(and (scale-subresource? %) 148 | (string/starts-with? (:name %) (:name resource))) 149 | subresources))) top-levels))) 150 | 151 | (defn crd-from-gvk? 152 | [crd gvk] 153 | (let [{:keys [group versions names]} (:spec crd)] 154 | (and (= (:group gvk) group) 155 | (some #(= % (:version gvk)) (map :name versions)) 156 | (= (:kind gvk) (:kind names))))) 157 | 158 | (defn swagger-from [extension-api 159 | {:keys [resources] :as _api-resources} 160 | {:keys [items] :as crds}] 161 | (->> (top-level-resources resources) 162 | (mapcat (fn [resource] 163 | (->> items 164 | (misc/find-first #(crd-from-gvk? % {:group (:api extension-api) 165 | :version (:version extension-api) 166 | :kind (:kind resource)})) 167 | (single-resource-swagger extension-api resource)))) 168 | (into {}) 169 | (hash-map :paths))) 170 | -------------------------------------------------------------------------------- /src/kubernetes_api/interceptors/auth.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.auth 2 | (:require [kubernetes-api.interceptors.auth.ssl :as auth.ssl] 3 | [tripod.log :as log])) 4 | 5 | (defn- ca-cert? [{:keys [ca-cert certificate-authority-data]}] 6 | (or (some? ca-cert) 7 | (some? certificate-authority-data))) 8 | 9 | (defn- client-cert? [{:keys [client-cert client-certificate-data]}] 10 | (or (some? client-cert) 11 | (some? client-certificate-data))) 12 | 13 | (defn- client-key? [{:keys [client-key client-key-data]}] 14 | (or (some? client-key) 15 | (some? client-key-data))) 16 | 17 | (defn- client-certs? [opts] 18 | (and (ca-cert? opts) (client-cert? opts) (client-key? opts))) 19 | 20 | (defn- basic-auth? [{:keys [username password]}] 21 | (every? some? [username password])) 22 | 23 | (defn- basic-auth [{:keys [username password]}] 24 | (str username ":" password)) 25 | 26 | (defn- token? [{:keys [token]}] 27 | (some? token)) 28 | 29 | (defn- token-fn? [{:keys [token-fn]}] 30 | (some? token-fn)) 31 | 32 | (defn request-auth-params [{:keys [token-fn insecure?] :as opts}] 33 | (merge 34 | {:insecure? (or insecure? false)} 35 | (when (and (ca-cert? opts) (not (client-certs? opts))) 36 | {:sslengine (auth.ssl/ca-cert->ssl-engine opts)}) 37 | (cond 38 | (basic-auth? opts) {:basic-auth (basic-auth opts)} 39 | (token? opts) {:oauth-token (:token opts)} 40 | (token-fn? opts) {:oauth-token (token-fn opts)} 41 | (client-certs? opts) {:sslengine (auth.ssl/client-certs->ssl-engine opts)} 42 | :else (do (log/info "No authentication method found") 43 | {})))) 44 | 45 | (defn new [opts] 46 | {:name ::authentication 47 | :enter (fn [context] 48 | (update context :request #(merge % (request-auth-params opts))))}) 49 | -------------------------------------------------------------------------------- /src/kubernetes_api/interceptors/auth/ssl.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.auth.ssl 2 | (:require [less.awful.ssl :as ssl]) 3 | (:import (java.io ByteArrayInputStream) 4 | (java.security KeyStore) 5 | (java.security.spec PKCS8EncodedKeySpec) 6 | (java.security.cert Certificate) 7 | (javax.net.ssl SSLContext 8 | TrustManager 9 | KeyManager))) 10 | 11 | (defn load-certificate ^Certificate [{:keys [cert-file cert-data]}] 12 | (cond 13 | (some? cert-file) (ssl/load-certificate cert-file) 14 | (some? cert-data) (with-open [stream (ByteArrayInputStream. (ssl/base64->binary cert-data))] 15 | (.generateCertificate ssl/x509-cert-factory stream)))) 16 | 17 | (defn trust-store [cert] 18 | (doto (KeyStore/getInstance "JKS") 19 | (.load nil nil) 20 | (.setCertificateEntry "cacert" (load-certificate cert)))) 21 | 22 | (defn base64->private-key 23 | [base64-private-key] 24 | (->> (String. (ssl/base64->binary base64-private-key) java.nio.charset.StandardCharsets/UTF_8) 25 | (re-find #"(?ms)^-----BEGIN ?.*? PRIVATE KEY-----$(.+)^-----END ?.*? PRIVATE KEY-----$") 26 | last 27 | ssl/base64->binary 28 | PKCS8EncodedKeySpec. 29 | (.generatePrivate ssl/rsa-key-factory))) 30 | 31 | (defn private-key [{:keys [key key-data]}] 32 | (cond 33 | (some? key) (ssl/private-key key) 34 | (some? key-data) (base64->private-key key-data))) 35 | 36 | (defn ^"[Ljava.security.cert.Certificate;" load-certificate-chain 37 | [{:keys [cert-file cert-data]}] 38 | (cond 39 | (some? cert-file) (ssl/load-certificate-chain cert-file) 40 | (some? cert-data) (with-open [stream (ByteArrayInputStream. (ssl/base64->binary cert-data))] 41 | (let [^"[Ljava.security.cert.Certificate;" ar (make-array Certificate 0)] 42 | (.toArray (.generateCertificates ssl/x509-cert-factory stream) ar))))) 43 | 44 | (defn key-store 45 | [key cert] 46 | (let [pk (private-key key) 47 | certs (load-certificate-chain cert)] 48 | (doto (KeyStore/getInstance (KeyStore/getDefaultType)) 49 | (.load nil nil) 50 | ; alias, private key, password, certificate chain 51 | (.setKeyEntry "cert" pk ssl/key-store-password certs)))) 52 | 53 | (defn client-certs->ssl-context ^SSLContext 54 | [client-key client-cert ca-cert] 55 | (let [key-manager (ssl/key-manager (key-store client-key client-cert)) 56 | trust-manager (ssl/trust-manager (trust-store ca-cert))] 57 | (doto (SSLContext/getInstance "TLSv1.2") 58 | (.init (into-array KeyManager [key-manager]) 59 | (into-array TrustManager [trust-manager]) 60 | nil)))) 61 | 62 | (defn client-certs->ssl-engine 63 | [{:keys [ca-cert certificate-authority-data client-cert client-certificate-data client-key client-key-data]}] 64 | (let [key {:key client-key 65 | :key-data client-key-data} 66 | cert {:cert-file client-cert 67 | :cert-data client-certificate-data} 68 | ca-crt {:cert-file ca-cert 69 | :cert-data certificate-authority-data}] 70 | (ssl/ssl-context->engine 71 | (client-certs->ssl-context key cert ca-crt)))) 72 | 73 | (defn ca-cert->ssl-context [cert] 74 | (doto (SSLContext/getInstance "TLSv1.2") 75 | (.init nil (into-array TrustManager [(less.awful.ssl/trust-manager (trust-store cert))]) nil))) 76 | 77 | (defn ca-cert->ssl-engine 78 | [{:keys [ca-cert certificate-authority-data]}] 79 | (ssl/ssl-context->engine 80 | (ca-cert->ssl-context {:cert-file ca-cert 81 | :cert-data certificate-authority-data}))) 82 | -------------------------------------------------------------------------------- /src/kubernetes_api/interceptors/encoders.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.encoders 2 | (:require martian.encoders 3 | martian.interceptors)) 4 | 5 | (defn patch-encoders [json] 6 | {"application/merge-patch+json" json 7 | "application/strategic-merge-patch+json" json 8 | "application/apply-patch+yaml" json 9 | "application/json-patch+json" json}) 10 | 11 | (defn default-encoders [] 12 | (let [encoders (martian.encoders/default-encoders)] 13 | (merge encoders (patch-encoders (get encoders "application/json"))))) 14 | 15 | 16 | (defn new [] 17 | (martian.interceptors/encode-body (default-encoders))) 18 | -------------------------------------------------------------------------------- /src/kubernetes_api/interceptors/raise.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.raise) 2 | 3 | (defn- status-error? [status] 4 | (or (nil? status) (>= status 400))) 5 | 6 | (def ^:private error-type->status-code 7 | {:bad-request 400 8 | :invalid-input 400 9 | :unauthorized 401 10 | :payment-required 402 11 | :forbidden 403 12 | :not-found 404 13 | :method-not-allowed 405 14 | :not-acceptable 406 15 | :proxy-authentication-required 407 16 | :timeout 408 17 | :conflict 409 18 | :gone 410 19 | :length-required 411 20 | :precondition-failed 412 21 | :payload-too-large 413 22 | :uri-too-long 414 23 | :unsupported-media-type 415 24 | :range-not-satisfiable 416 25 | :expectation-failed 417 26 | :unprocessable-entity 422 27 | :locked 423 28 | :upgrade-required 426 29 | :too-many-requests 429 30 | :server-error 500 31 | :not-implemented 501 32 | :bad-gateway 502 33 | :service-unavailable 503 34 | :gateway-timeout 504 35 | :http-version-not-supported 505}) 36 | 37 | (def ^:private status-code->error-type (zipmap (vals error-type->status-code) (keys error-type->status-code))) 38 | 39 | (defn- make-exception [{:keys [status] :as response}] 40 | (ex-info (str "APIServer error: " status) 41 | {:type (status-code->error-type status) 42 | :response response})) 43 | 44 | (defn check-response 45 | "Checks the status code. If 400+, raises an exception, returns body otherwise" 46 | [response] 47 | (cond 48 | (:error response) (throw (:error response)) 49 | (status-error? (:status response)) (throw (make-exception response)) 50 | :else (:body response))) 51 | 52 | (defn- maybe-assoc-error [{:keys [error status body] :as response}] 53 | (cond 54 | error (assoc response :kubernetes-api.core/error error) 55 | (status-error? status) (assoc response :kubernetes-api.core/error (make-exception response)) 56 | :else body)) 57 | 58 | (defn new [_] 59 | {:name ::raise 60 | :leave (fn [{:keys [request response]}] 61 | (with-meta 62 | {:response (maybe-assoc-error response)} 63 | {:response response :request request}))}) 64 | -------------------------------------------------------------------------------- /src/kubernetes_api/internals/client.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.internals.client 2 | (:require [camel-snake-kebab.core :as csk] 3 | [clojure.string :as string] 4 | [kubernetes-api.misc :as misc])) 5 | 6 | (defn pascal-case-routes [k8s] 7 | (update k8s :handlers 8 | (fn [handlers] 9 | (mapv #(update % :route-name csk/->PascalCase) handlers)))) 10 | 11 | (defn patch-http-verb [k8s] 12 | (assoc k8s :handlers 13 | (mapv (fn [{:keys [method] :as handler}] 14 | (if (#{"apply" "patch"} (namespace method)) 15 | (assoc handler :method :patch) 16 | handler)) 17 | (:handlers k8s)))) 18 | 19 | (defn transform [k8s] 20 | (-> k8s 21 | pascal-case-routes 22 | patch-http-verb)) 23 | 24 | (defn swagger-definition-for-route [k8s route-name] 25 | (->> (:handlers k8s) 26 | (misc/find-first #(= route-name (:route-name %))) 27 | :swagger-definition)) 28 | 29 | (defn handler-kind [handler] 30 | (-> handler :swagger-definition :x-kubernetes-group-version-kind :kind keyword)) 31 | 32 | (defn handler-group [handler] 33 | (-> handler :swagger-definition :x-kubernetes-group-version-kind :group)) 34 | 35 | (defn handler-version [handler] 36 | (-> handler :swagger-definition :x-kubernetes-group-version-kind :version)) 37 | 38 | (defn handler-action [handler] 39 | (-> handler :swagger-definition :x-kubernetes-action keyword)) 40 | 41 | (defn all-namespaces-route? [route-name] 42 | (string/ends-with? (name route-name) "ForAllNamespaces")) 43 | 44 | (defn scale-resource [route-name] 45 | (second (re-matches #".*Namespaced([A-Za-z]*)Scale" (name route-name)))) 46 | 47 | (defn status-route? [route-name] 48 | (re-matches #".*Status(JsonPatch|StrategicMerge|JsonMerge|ApplyServerSide)?" (name route-name))) 49 | 50 | (defn kind 51 | "Returns a kubernetes-api kind. Similar to handler-kind, but deals with some 52 | corner-cases. Returns a keyword, that is namespaced only if there's a 53 | subresource. 54 | 55 | Example: 56 | Deployment/Status" 57 | [{:keys [route-name] :as handler}] 58 | (let [kind (some-> (handler-kind handler) name)] 59 | (cond 60 | (status-route? route-name) (keyword kind "Status") 61 | (string/ends-with? (name route-name) "Scale") (keyword (scale-resource route-name) "Scale") 62 | :else (keyword kind)))) 63 | 64 | (defn action 65 | "Return a kubernetes-api action. Similar to handler-action, but tries to be 66 | unique for each kind. 67 | 68 | Example: 69 | :list-all" 70 | [{:keys [route-name method] :as handler}] 71 | (cond 72 | (re-matches #"Create.*NamespacedPodBinding" (name route-name)) :pod/create 73 | (re-matches #"Connect.*ProxyWithPath" (name route-name)) (keyword "connect.with-path" (name method)) 74 | (re-matches #"Connect.*Proxy" (name route-name)) (keyword "connect" (name method)) 75 | (re-matches #"Connect.*" (name route-name)) (keyword "connect" (name method)) 76 | (re-matches #"ReplaceCertificates.*CertificateSigningRequestApproval" (name route-name)) :replace-approval 77 | (re-matches #"Read.*NamespacedPodLog" (name route-name)) :misc/logs 78 | (re-matches #"Replace.*NamespaceFinalize" (name route-name)) :misc/finalize 79 | (all-namespaces-route? route-name) (keyword (str (name (handler-action handler)) "-all")) 80 | :else (handler-action handler))) 81 | 82 | (defn find-route [k8s {:keys [all-namespaces?] :as _search-params 83 | search-kind :kind 84 | search-action :action}] 85 | (->> (:handlers k8s) 86 | (filter (fn [handler] 87 | (and (or (= (keyword search-kind) (kind handler)) (nil? search-kind)) 88 | (or (= (keyword search-action) (action handler)) (nil? search-action)) 89 | (= (boolean all-namespaces?) (all-namespaces-route? (:route-name handler)))))) 90 | (map :route-name))) 91 | 92 | (defn version-of [k8s route-name] 93 | (->> (swagger-definition-for-route k8s route-name) 94 | :x-kubernetes-group-version-kind 95 | :version)) 96 | 97 | (defn group-of [k8s route-name] 98 | (->> (swagger-definition-for-route k8s route-name) 99 | :x-kubernetes-group-version-kind 100 | :group)) 101 | 102 | (defn kind-of [k8s route-name] 103 | (->> (swagger-definition-for-route k8s route-name) 104 | :x-kubernetes-group-version-kind 105 | :kind 106 | keyword)) 107 | 108 | (defn core-versions [k8s] 109 | (mapv 110 | #(hash-map :name "" 111 | :versions [{:groupVersion % :version %}] 112 | :preferredVersion {:groupVersion % :version %}) 113 | (:versions (:kubernetes-api.core/core-api-versions k8s)))) 114 | 115 | (defn all-versions [k8s] 116 | (concat (:groups (:kubernetes-api.core/api-group-list k8s)) 117 | (core-versions k8s))) 118 | 119 | (defn ^:private choose-preffered-version [k8s route-names] 120 | (misc/find-first 121 | (fn [route] 122 | (some #(and (= (:name %) (group-of k8s route)) 123 | (= (:version (:preferredVersion %)) (version-of k8s route))) 124 | (all-versions k8s))) 125 | route-names)) 126 | 127 | (defn find-preferred-route [k8s search-params] 128 | (->> (find-route k8s search-params) 129 | (filter (fn [x] (not (string/ends-with? (name x) "Status")))) 130 | ((partial choose-preffered-version k8s)))) 131 | 132 | (defn preffered-version? [k8s handler] 133 | (let [preffered-route (find-preferred-route k8s {:kind (handler-kind handler) 134 | :action (handler-action handler)})] 135 | (and (= (handler-version handler) (version-of k8s preffered-route)) 136 | (= (handler-group handler) (group-of k8s preffered-route))))) 137 | -------------------------------------------------------------------------------- /src/kubernetes_api/internals/martian.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.internals.martian 2 | (:require [martian.core :as martian])) 3 | 4 | (defn response-for 5 | "Workaround to throw exceptions in the client like connection timeout" 6 | [& args] 7 | (let [{:kubernetes-api.core/keys [error] :as response} (deref (apply martian/response-for args))] 8 | (if (instance? Throwable error) 9 | (throw error) 10 | response))) 11 | -------------------------------------------------------------------------------- /src/kubernetes_api/listeners.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.listeners 2 | (:refer-clojure :exclude [delay]) 3 | (:require [kubernetes-api.core :as k8s-api] 4 | [martian.core :as martian]) 5 | (:import (java.util UUID) 6 | (java.util.concurrent Executors TimeUnit))) 7 | 8 | (defn- new-executor [size] (Executors/newScheduledThreadPool size)) 9 | 10 | (defn schedule-with-delay-seconds [pool runnable num-seconds] 11 | (.schedule pool runnable num-seconds TimeUnit/SECONDS)) 12 | 13 | (defn schedule-periodic-seconds [pool runnable num-seconds] 14 | (.scheduleAtFixedRate pool runnable num-seconds num-seconds TimeUnit/SECONDS)) 15 | 16 | (defn cancel-task [task] 17 | (.cancel task true)) 18 | 19 | (defn task-cancelled? [task] 20 | (.isCancelled task)) 21 | 22 | (defn delay [task] 23 | (.getDelay task TimeUnit/SECONDS)) 24 | 25 | (defn status-task [task] 26 | (cond 27 | (task-cancelled? task) :cancelled 28 | :else :registered)) 29 | 30 | (defn status [{:keys [state] :as context} listener-id] 31 | (let [listener (get-in @state [:listeners listener-id])] 32 | {:id listener-id 33 | :status (status-task (:task listener))})) 34 | 35 | (defn new-context 36 | "Creates a context for running listeners for kubernetes objects 37 | 38 | client: kubernetes-api client 39 | thread-pool-size: number of threads for polling 40 | polling-rate: seconds of delay between requests" 41 | [{:keys [client thread-pool-size polling-rate] 42 | :or {thread-pool-size 1 43 | polling-rate 1}}] 44 | {:client client 45 | :thread-pool-size thread-pool-size 46 | :polling-rate polling-rate 47 | :executer (new-executor thread-pool-size) 48 | :state (atom {:listeners {}})}) 49 | 50 | (defn random-uuid [] 51 | (UUID/randomUUID)) 52 | 53 | (defn action [kind] 54 | (case kind 55 | :Deployment :ReadAppsV1NamespacedDeployment)) 56 | 57 | (defn handler-fn 58 | [{:keys [client state] :as _context} 59 | {:keys [id kind namespace name] :as params} 60 | listener-fn] 61 | (fn [] 62 | (let [current-version (get-in @state [:listeners id :version]) 63 | resp (k8s-api/invoke client params) 64 | new-version (get-in resp [:metadata :resourceVersion])] 65 | (when (not= current-version new-version) 66 | (listener-fn resp) 67 | (swap! state 68 | (fn [st] 69 | (assoc-in st [:listeners id :version] new-version))))))) 70 | 71 | (defn register 72 | [{:keys [executer state polling-rate] :as context} 73 | params 74 | listener-fn] 75 | (let [listener-id (random-uuid)] 76 | (swap! state 77 | (fn [s] 78 | (assoc-in s [:listeners listener-id :task] 79 | (schedule-periodic-seconds executer 80 | (handler-fn context params listener-fn) 81 | polling-rate)))) 82 | listener-id)) 83 | 84 | (defn print-version 85 | [deployment] 86 | (prn (get-in deployment [:metadata :resourceVersion]))) 87 | 88 | (defn deregister 89 | [{:keys [state] :as context} 90 | id] 91 | (cancel-task (get-in @state [:listeners id :task]))) 92 | 93 | -------------------------------------------------------------------------------- /src/kubernetes_api/misc.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.misc) 2 | 3 | (defn find-first [pred coll] 4 | (first (filter pred coll))) 5 | 6 | (defn indexes-of [pred coll] 7 | (keep-indexed #(when (pred %2) %1) coll)) 8 | 9 | (defn first-index-of [pred coll] 10 | (first (indexes-of pred coll))) 11 | 12 | (defn map-vals [f coll] 13 | (into {} (map (fn [[k v]] [k (f v)]) coll))) 14 | 15 | (defn map-keys [f coll] 16 | (into {} (map (fn [[k v]] [(f k) v]) coll))) 17 | 18 | (defn assoc-some 19 | "Assoc[iate] if the value is not nil. 20 | Examples: 21 | (assoc-some {:a 1} :b false) => {:a 1 :b false} 22 | (assoc-some {:a 1} :b nil) => {:a 1}" 23 | ([m k v] 24 | (if (nil? v) m (assoc m k v))) 25 | ([m k v & kvs] 26 | (let [ret (assoc-some m k v)] 27 | (if kvs 28 | (if (next kvs) 29 | (recur ret (first kvs) (second kvs) (nnext kvs)) 30 | (throw (IllegalArgumentException. 31 | "assoc-some expects even number of arguments after map/vector, found odd number"))) 32 | ret)))) 33 | -------------------------------------------------------------------------------- /src/kubernetes_api/swagger.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.swagger 2 | (:refer-clojure :exclude [read]) 3 | (:require [cheshire.core :as json] 4 | [clojure.java.io :as io] 5 | [clojure.string :as string] 6 | [clojure.walk :as walk] 7 | [kubernetes-api.interceptors.auth :as interceptors.auth] 8 | [kubernetes-api.interceptors.raise :as interceptors.raise] 9 | [org.httpkit.client :as http])) 10 | 11 | (defn remove-watch-endpoints 12 | "Watch endpoints doesn't follow the http1.1 specification, so it will not work 13 | with httpkit or similar. 14 | 15 | Related: https://github.com/kubernetes/kubernetes/issues/50857" 16 | [swagger] 17 | (update swagger :paths 18 | (fn [paths] 19 | (apply dissoc (concat [paths] 20 | (filter (fn* [p1__944608#] (string/includes? p1__944608# "/watch")) (keys paths))))))) 21 | 22 | (defn fix-k8s-verb 23 | "For some reason, the x-kubernetes-action given by the kubernetes api doesn't 24 | respect its own specification :shrug: 25 | 26 | More info: https://kubernetes.io/docs/reference/access-authn-authz/authorization/#determine-the-request-verb" 27 | [swagger] 28 | (walk/postwalk (fn [{:keys [x-kubernetes-action] :as form}] 29 | (if x-kubernetes-action 30 | (assoc form :x-kubernetes-action 31 | (case (keyword x-kubernetes-action) 32 | :post "create" 33 | :put "update" 34 | :watchlist "watch" 35 | x-kubernetes-action)) 36 | form)) 37 | swagger)) 38 | 39 | (defn fix-consumes 40 | "Some endpoints declares that consumes */* which is not true and it doesn't 41 | let us select the correct encoder" 42 | [swagger] 43 | (walk/postwalk (fn [{:keys [consumes] :as form}] 44 | (if (= consumes ["*/*"]) 45 | (assoc form :consumes ["application/json"]) 46 | form)) 47 | swagger)) 48 | 49 | (defn add-summary [swagger] 50 | (walk/postwalk (fn [{:keys [description summary] :as form}] 51 | (if description 52 | (assoc form :summary (or summary description)) 53 | form)) 54 | swagger)) 55 | 56 | (def arbitrary-api-resources-route 57 | {"/apis/{api}/{version}/" 58 | {:get {:consumes ["application/json" 59 | "application/yaml" 60 | "application/vnd.kubernetes.protobuf"] 61 | :summary "get available resources for arbitrary api" 62 | :operationId "GetArbitraryAPIResources" 63 | :produces ["application/json" 64 | "application/yaml" 65 | "application/vnd.kubernetes.protobuf"] 66 | :responses {"200" {:description "OK" 67 | :schema {:$ref "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList"}} 68 | "401" {:description "Unauthorized"}} 69 | :schemes ["https"]} 70 | :parameters [{:in "path" 71 | :name "api" 72 | :schema {:type "string"}} 73 | {:in "path" 74 | :name "version" 75 | :schema {:type "string"}}]}}) 76 | 77 | (defn add-some-routes 78 | [swagger new-definitions new-routes] 79 | (-> swagger 80 | (update :paths #(merge % new-routes)) 81 | (update :definitions #(merge % new-definitions)))) 82 | 83 | (defn replace-swagger-body-schema [params new-body-schema] 84 | (mapv (fn [param] 85 | (if (= "body" (:name param)) 86 | (assoc param :schema new-body-schema) 87 | param)) 88 | params)) 89 | 90 | (def rfc6902-json-schema 91 | {:type "array" 92 | :items {:type "object" 93 | :required [:op :path :value] 94 | :properties {:op {:type "string" 95 | :enum ["add" "remove" "replace" "move" "test"]} 96 | :path {:type "string"} 97 | :value {}}}}) 98 | 99 | (defn patch-operations [operation update-schema] 100 | (letfn [(custom-operation [{:keys [schema content-type action summary route-name-suffix]}] 101 | (-> operation 102 | (update :parameters #(replace-swagger-body-schema % schema)) 103 | (update :operationId #(str % route-name-suffix)) 104 | (assoc :consumes [content-type]) 105 | (assoc :summary (format (or summary (:summary operation)) (-> operation :x-kubernetes-group-version-kind :kind))) 106 | (assoc :x-kubernetes-action action)))] 107 | {:patch/json (custom-operation {:schema rfc6902-json-schema 108 | :content-type "application/json-patch+json" 109 | :summary "update the specified %s using RFC6902" 110 | :route-name-suffix "JsonPatch" 111 | :action "patch/json"}) 112 | :patch/strategic (custom-operation {:schema update-schema 113 | :content-type "application/strategic-merge-patch+json" 114 | :summary "update the specified %s using a smart strategy" 115 | :route-name-suffix "StrategicMerge" 116 | :action "patch/strategic"}) 117 | :patch/json-merge (custom-operation {:schema update-schema 118 | :content-type "application/merge-patch+json" 119 | :summary "update the specified %s using RFC7286" 120 | :route-name-suffix "JsonMerge" 121 | :action "patch/json-merge"}) 122 | :apply/server (custom-operation {:schema update-schema 123 | :content-type "application/apply-patch+yaml" 124 | :summary "create or update the specified %s using server side apply" 125 | :route-name-suffix "ApplyServerSide" 126 | :action "apply/server"})})) 127 | 128 | (defn update-path-item [swagger update-fn] 129 | (update swagger :paths 130 | (fn [paths] 131 | (into {} (map (fn [[path item]] [path (update-fn path item)]) paths))))) 132 | 133 | (defn find-update-body-schema [swagger path] 134 | (->> (select-keys (get-in swagger [:paths path]) [:put :post]) 135 | vals 136 | (filter (fn [{:keys [x-kubernetes-action]}] (= x-kubernetes-action "update"))) 137 | (mapcat :parameters) 138 | (filter (fn [param] (= (:name param) "body"))) 139 | (map :schema) 140 | first)) 141 | 142 | (defn add-patch-routes [swagger] 143 | (-> swagger 144 | (update-path-item (fn [path item] 145 | (->> item 146 | (mapcat (fn [[verb operation]] 147 | (if (= verb :patch) 148 | (patch-operations operation (find-update-body-schema swagger path)) 149 | [[verb operation]]))) 150 | (into {})))))) 151 | 152 | (defn group-version [api] 153 | (letfn [(group [s] (if (= s "core") "" s))] 154 | (cond 155 | (nil? api) nil 156 | (string? api) (group-version (keyword api)) 157 | (and (keyword? api) (seq (namespace api))) {:group (group (namespace api)) 158 | :version (name api)} 159 | (keyword? api) {:group (group (name api))}))) 160 | 161 | (defn from-group-version? [api path] 162 | (let [{:keys [group version]} (group-version api)] 163 | (if (= group "") 164 | (string/starts-with? path (str "/api/" version)) 165 | (string/starts-with? path (str "/apis/" group "/" version))))) 166 | 167 | (defn from-apis 168 | "Returns the default paths and the paths for the given apis" 169 | [apis paths] 170 | (->> paths 171 | (filter (fn [[path _]] (or (not (string/starts-with? path "/api")) 172 | (#{"/apis/" "/api/"} path) 173 | (some #(from-group-version? % path) apis)))) 174 | (into {}))) 175 | 176 | (defn filter-paths 177 | "Returns an updated schema with the paths for the given apis" 178 | [schema apis] 179 | (update schema :paths (partial from-apis apis))) 180 | 181 | (defn ^:private customized 182 | "Receives a kubernetes swagger, adds a description to the routes and some 183 | generic routes" 184 | [swagger opts] 185 | (-> swagger 186 | (filter-paths (:apis opts)) 187 | add-summary 188 | (add-some-routes {} arbitrary-api-resources-route) 189 | fix-k8s-verb 190 | fix-consumes 191 | remove-watch-endpoints 192 | add-patch-routes)) 193 | 194 | (defn ^:private keyword-except-paths [s] 195 | (if (string/starts-with? s "/") 196 | s 197 | (keyword s))) 198 | 199 | (defn openapi-discovery-enabled? 200 | [opts] 201 | (not= :disabled (get-in opts [:openapi :discovery]))) 202 | 203 | (defn parse-swagger-file 204 | "Reads a swagger file and returns a clojure map with the swagger data" 205 | [file] 206 | (-> (io/resource file) 207 | io/input-stream 208 | slurp 209 | (json/parse-string keyword-except-paths))) 210 | 211 | (defn read [opts] 212 | (customized (parse-swagger-file "kubernetes_api/swagger.json") opts)) 213 | 214 | (defn from-api* [api-root opts] 215 | (json/parse-string 216 | (interceptors.raise/check-response 217 | @(http/request (merge {:url (str api-root "/openapi/v2") 218 | :method :get} 219 | (interceptors.auth/request-auth-params opts)))) 220 | keyword-except-paths)) 221 | 222 | (defn from-api [api-root opts] 223 | (try 224 | (when (openapi-discovery-enabled? opts) 225 | (customized (from-api* api-root opts) opts)) 226 | (catch Exception _ 227 | nil))) 228 | -------------------------------------------------------------------------------- /test/kubernetes_api/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.core-test 2 | (:require [clojure.test :refer :all] 3 | [kubernetes-api.core :as k8s-api])) 4 | 5 | ;; TODO 6 | -------------------------------------------------------------------------------- /test/kubernetes_api/extensions/custom_resource_definition_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.extensions.custom-resource-definition-test 2 | (:require [clojure.test :refer :all] 3 | [kubernetes-api.extensions.custom-resource-definition :as crd] 4 | [matcher-combinators.test])) 5 | 6 | (deftest new-route-name-test 7 | (testing "list" 8 | (is (= "ListTektonDevV1alpha1NamespacedTask" 9 | (crd/new-route-name "list" "tekton.dev" "v1alpha1" "Namespaced" "Task" {}))) 10 | (is (= "ListTektonDevV1alpha1TaskForAllNamespaces" 11 | (crd/new-route-name "list" "tekton.dev" "v1alpha1" "Namespaced" "Task" {:all-namespaces true})))) 12 | 13 | (testing "get" 14 | (is (= "ReadTektonDevV1alpha1NamespacedTask" 15 | (crd/new-route-name "get" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 16 | 17 | (testing "create" 18 | (is (= "CreateTektonDevV1alpha1NamespacedTask" 19 | (crd/new-route-name "create" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 20 | 21 | (testing "deletecollection" 22 | (is (= "DeleteTektonDevV1alpha1CollectionNamespacedTask" 23 | (crd/new-route-name "deletecollection" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 24 | 25 | (testing "delete" 26 | (is (= "DeleteTektonDevV1alpha1NamespacedTask" 27 | (crd/new-route-name "delete" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 28 | 29 | (testing "patch" 30 | (is (= "PatchTektonDevV1alpha1NamespacedTask" 31 | (crd/new-route-name "patch" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 32 | 33 | (testing "update" 34 | (is (= "ReplaceTektonDevV1alpha1NamespacedTask" 35 | (crd/new-route-name "update" "tekton.dev" "v1alpha1" "Namespaced" "Task" {})))) 36 | 37 | (testing "watch" 38 | (is (= "WatchTektonDevV1alpha1NamespacedTask" 39 | (crd/new-route-name "watch" "tekton.dev" "v1alpha1" "Namespaced" "Task" {}))) 40 | (is (= "WatchTektonDevV1alpha1TaskList" 41 | (crd/new-route-name "watch" "tekton.dev" "v1alpha1" nil "TaskList" {}))) 42 | (is (= "WatchTektonDevV1alpha1TaskListForAllNamespaces" 43 | (crd/new-route-name "watch" "tekton.dev" "v1alpha1" nil "TaskList" {:all-namespaces true}))))) 44 | 45 | (deftest swagger-from-test 46 | (testing "it generated CustomResourceDefinition paths" 47 | (is (match? 48 | {:paths {"/apis/tekton.dev/v1alpha1/tasks" 49 | {:get {:operationId "ListTektonDevV1alpha1TaskForAllNamespaces"}} 50 | 51 | "/apis/tekton.dev/v1alpha1/namespaces/{namespace}/tasks" 52 | {:get {:operationId "ListTektonDevV1alpha1NamespacedTask"} 53 | :post {:operationId "CreateTektonDevV1alpha1NamespacedTask"} 54 | :delete {:operationId "DeleteTektonDevV1alpha1CollectionNamespacedTask"}} 55 | 56 | "/apis/tekton.dev/v1alpha1/namespaces/{namespace}/tasks/{name}" 57 | {:get {:operationId "ReadTektonDevV1alpha1NamespacedTask"} 58 | :patch {:operationId "PatchTektonDevV1alpha1NamespacedTask"} 59 | :put {:operationId "ReplaceTektonDevV1alpha1NamespacedTask"} 60 | :delete {:operationId "DeleteTektonDevV1alpha1NamespacedTask"}}}} 61 | (crd/swagger-from 62 | {:api "tekton.dev" 63 | :version "v1alpha1"} 64 | {:kind "APIResourceList" 65 | :groupVersion "tekton.dev/v1alpha1" 66 | :resources [{:name "tasks", 67 | :singularName "task", 68 | :namespaced true, 69 | :kind "Task", 70 | :verbs ["delete" "deletecollection" "get" "list" "patch" "create" "update" "watch"], 71 | :categories ["all" "tekton-pipelines"], 72 | :storageVersionHash "Vwu99D/K4xM="}]} 73 | {:kind "CustomResourceDefinitionList" 74 | :items [{:spec {:group "tekton.dev" 75 | :version "v1alpha1" 76 | :names {:singular "task" 77 | :plural "tasks" 78 | :kind "Task" 79 | :listKind "TaskList" 80 | :categories ["all" "tekton-pipelines"]} 81 | :scope "Namespaced" 82 | :subresources {:status {}} 83 | :versions [{:name "v1alpha1" 84 | :served true 85 | :storage true 86 | :schema {:openAPIV3Schema 87 | {:type "object"}}}]}}]}))))) 88 | -------------------------------------------------------------------------------- /test/kubernetes_api/interceptors/auth_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.auth-test 2 | (:require [clojure.test :refer :all] 3 | [kubernetes-api.interceptors.auth :as interceptors.auth] 4 | [kubernetes-api.interceptors.auth.ssl :as auth.ssl] 5 | [matcher-combinators.standalone :refer [match?]] 6 | [matcher-combinators.test] 7 | [mockfn.macros])) 8 | 9 | (deftest auth-test 10 | (testing "request with basic-auth" 11 | (is (match? {:request {:basic-auth "floki:freya1234"}} 12 | ((:enter (interceptors.auth/new {:username "floki" 13 | :password "freya1234"})) {})))) 14 | (testing "request with client certificate" 15 | (mockfn.macros/providing 16 | [(auth.ssl/client-certs->ssl-engine {:client-cert "/some/client.crt" 17 | :client-key "/some/client.key" 18 | :ca-cert "/some/ca-cert.crt"}) 'SSLEngine] 19 | (is (match? {:request {:sslengine #(= 'SSLEngine %)}} 20 | ((:enter (interceptors.auth/new {:client-cert "/some/client.crt" 21 | :client-key "/some/client.key" 22 | :ca-cert "/some/ca-cert.crt"})) {}))))) 23 | (testing "request with token" 24 | (is (match? {:request {:oauth-token "TOKEN"}} 25 | ((:enter (interceptors.auth/new {:token "TOKEN"})) {})))) 26 | (testing "request with token-fn" 27 | (is (match? {:request {:oauth-token "TOKEN"}} 28 | ((:enter (interceptors.auth/new {:token-fn (constantly "TOKEN")})) {})))) 29 | (testing "request with token and ca-certificate" 30 | (mockfn.macros/providing 31 | [(auth.ssl/ca-cert->ssl-engine (match? {:ca-cert "/some/ca-cert.crt"})) 'SSLEngine] 32 | (is (match? {:request {:sslengine #(= 'SSLEngine %) 33 | :oauth-token "TOKEN"}} 34 | ((:enter (interceptors.auth/new {:token "TOKEN" 35 | :ca-cert "/some/ca-cert.crt"})) {})))))) 36 | -------------------------------------------------------------------------------- /test/kubernetes_api/interceptors/raise_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.interceptors.raise-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [kubernetes-api.interceptors.raise :as interceptors.raise] 4 | [matcher-combinators.matchers :as m] 5 | [matcher-combinators.test :refer [match?]] 6 | [tripod.context :as tc])) 7 | 8 | (defn- run-interceptor [interceptor input] 9 | (tc/execute (tc/enqueue input interceptor))) 10 | 11 | (deftest raise-test 12 | (let [raise-interceptor (interceptors.raise/new {})] 13 | (testing "should raise the body to be the response on 2xx status" 14 | (is (match? {:response {:my :body}} 15 | (run-interceptor raise-interceptor {:response {:status 200 :body {:my :body}}})))) 16 | 17 | (testing "should have the request/response on metadata" 18 | (is (match? {:request {:my :request} 19 | :response {:status 200 20 | :body {:my :body}}} 21 | (meta 22 | (run-interceptor raise-interceptor {:request {:my :request} 23 | :response {:status 200 24 | :body {:my :body}}}))))) 25 | 26 | (testing "return an exception on 4XX responses" 27 | (is (match? (m/via (comp ex-data :kubernetes-api.core/error :response) 28 | {:type :bad-request, 29 | :response {:status 400}}) 30 | (run-interceptor raise-interceptor {:response {:status 400}})))))) 31 | -------------------------------------------------------------------------------- /test/kubernetes_api/internals/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.internals.client-test 2 | (:require [clojure.test :refer :all] 3 | [kubernetes-api.internals.client :as internals.client] 4 | [matcher-combinators.test :refer [match?]] 5 | [matcher-combinators.matchers :as m])) 6 | 7 | (deftest pascal-case-routes-test 8 | (is (= {:handlers [{:route-name :FooBar} 9 | {:route-name :DuDuduEdu}]} 10 | (internals.client/pascal-case-routes {:handlers [{:route-name :foo-bar} 11 | {:route-name :du-dudu-edu}]})))) 12 | 13 | (deftest patch-http-verb-test 14 | (is (= {:handlers [{:route-name :Route66 15 | :method :patch} 16 | {:route-name :Route67 17 | :method :patch} 18 | {:route-name :Route68 19 | :method :patch} 20 | {:route-name :Route69 21 | :method :patch} 22 | {:route-name :Route70 23 | :method :get}]} 24 | (internals.client/patch-http-verb {:handlers [{:route-name :Route66 25 | :method :patch/json} 26 | {:route-name :Route67 27 | :method :patch/json-merge} 28 | {:route-name :Route68 29 | :method :patch/strategic} 30 | {:route-name :Route69 31 | :method :apply/server} 32 | {:route-name :Route70 33 | :method :get}]})))) 34 | 35 | (deftest swagger-definition-for-route-test 36 | (is (= 'swagger-definition 37 | (internals.client/swagger-definition-for-route {:handlers [{:route-name :FooBar 38 | :swagger-definition 'swagger-definition} 39 | {:route-name :FooBarBaz 40 | :swagger-definition 'other-swagger-definition}]} 41 | :FooBar)))) 42 | 43 | (deftest handler-functions-test 44 | (let [handler {:route-name :CreateV1NamespacedDeployment 45 | :swagger-definition {:x-kubernetes-action :create 46 | :x-kubernetes-group-version-kind {:group "" 47 | :version "v1" 48 | :kind "Deployment"}}}] 49 | (is (= :create (internals.client/handler-action handler))) 50 | (is (= "" (internals.client/handler-group handler))) 51 | (is (= "v1" (internals.client/handler-version handler))) 52 | (is (= :Deployment (internals.client/handler-kind handler))))) 53 | 54 | (deftest all-namespaces-route?-test 55 | (is (internals.client/all-namespaces-route? :GetDeploymentForAllNamespaces)) 56 | (is (not (internals.client/all-namespaces-route? :GetDeployment)))) 57 | 58 | (deftest scale-resource-test 59 | (is (= "Deployment" (internals.client/scale-resource :PatchNamespacedDeploymentScale))) 60 | (is (= "ReplicaSet" (internals.client/scale-resource :PatchNamespacedReplicaSetScale)))) 61 | 62 | (deftest kind-test 63 | (is (= :Deployment 64 | (internals.client/kind {:route-name :CreateV1NamespacedDeployment 65 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}}))) 66 | 67 | (is (= :Deployment/Scale 68 | (internals.client/kind {:route-name :CreateAutoscalingNamespacedDeploymentScale 69 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Scale"}}}))) 70 | 71 | (is (= :Deployment/Status 72 | (internals.client/kind {:route-name :CreateV1NamespacedDeploymentStatus 73 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}}))) 74 | 75 | (is (= :Deployment/Status 76 | (internals.client/kind {:route-name :CreateV1NamespacedDeploymentStatusJsonPatch 77 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}}))) 78 | 79 | (is (= :Deployment/Status 80 | (internals.client/kind {:route-name :CreateV1NamespacedDeploymentStatusStrategicMerge 81 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}}))) 82 | 83 | (is (= :Deployment/Status 84 | (internals.client/kind {:route-name :CreateV1NamespacedDeploymentStatusJsonMerge 85 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}}))) 86 | 87 | (is (= :Deployment/Status 88 | (internals.client/kind {:route-name :CreateV1NamespacedDeploymentStatusApplyServerSide 89 | :swagger-definition {:x-kubernetes-group-version-kind {:kind "Deployment"}}})))) 90 | 91 | (deftest action-test 92 | (is (= :create (internals.client/action {:route-name :ItDoesntMatter 93 | :swagger-definition {:x-kubernetes-action "create"}}))) 94 | (is (= :list-all (internals.client/action {:route-name :ReadXForAllNamespaces 95 | :swagger-definition {:x-kubernetes-action "list"}}))) 96 | (testing "Binding special case" 97 | (is (= :pod/create (internals.client/action {:route-name :CreateFooNamespacedPodBinding 98 | :swagger-definition {:x-kubernetes-action "create"}})))) 99 | (testing "Connect special case" 100 | (is (= :connect.with-path/head (internals.client/action {:route-name :ConnectFooProxyWithPath 101 | :method "head" 102 | :swagger-definition {:x-kubernetes-action "connect"}}))) 103 | (is (= :connect.with-path/post (internals.client/action {:route-name :ConnectFooProxyWithPath 104 | :method "post" 105 | :swagger-definition {:x-kubernetes-action "connect"}}))) 106 | (is (= :connect/head (internals.client/action {:route-name :ConnectFooProxy 107 | :method "head" 108 | :swagger-definition {:x-kubernetes-action "connect"}}))) 109 | (is (= :connect/post (internals.client/action {:route-name :ConnectFooProxy 110 | :method "post" 111 | :swagger-definition {:x-kubernetes-action "connect"}}))) 112 | (is (= :connect/head (internals.client/action {:route-name :ConnectFoo 113 | :method "head" 114 | :swagger-definition {:x-kubernetes-action "connect"}}))) 115 | (is (= :connect/post (internals.client/action {:route-name :ConnectFoo 116 | :method "post" 117 | :swagger-definition {:x-kubernetes-action "connect"}})))) 118 | (testing "Approval certificate patch" 119 | (is (= :replace-approval (internals.client/action {:route-name :ReplaceCertificatesFooCertificateSigningRequestApproval 120 | :swagger-definition {:x-kubernetes-action "put"}})))) 121 | (testing "Pod misc" 122 | (is (= :misc/logs (internals.client/action {:route-name :ReadV1NamespacedPodLog}))) 123 | (is (= :misc/finalize (internals.client/action {:route-name :ReplaceV1NamespaceFinalize}))))) 124 | 125 | (def example-k8s {:handlers [{:route-name :CreateV1alpha1Orange 126 | :swagger-definition {:x-kubernetes-action "create" 127 | :x-kubernetes-group-version-kind {:group "fruits" 128 | :version "v1alpha1" 129 | :kind "Orange"}}} 130 | {:route-name :ReplaceV1alpha1Orange 131 | :swagger-definition {:x-kubernetes-action "update" 132 | :x-kubernetes-group-version-kind {:group "fruits" 133 | :version "v1alpha1" 134 | :kind "Orange"}}} 135 | {:route-name :DeleteV1alpha1Orange 136 | :swagger-definition {:x-kubernetes-action "delete" 137 | :x-kubernetes-group-version-kind {:group "fruits" 138 | :version "v1alpha1" 139 | :kind "Orange"}}} 140 | {:route-name :GetV1alpha1Orange 141 | :swagger-definition {:x-kubernetes-action "get" 142 | :x-kubernetes-group-version-kind {:group "fruits" 143 | :version "v1alpha1" 144 | :kind "Orange"}}} 145 | {:route-name :ListV1alpha1Orange 146 | :swagger-definition {:x-kubernetes-action "list" 147 | :x-kubernetes-group-version-kind {:group "fruits" 148 | :version "v1alpha1" 149 | :kind "Orange"}}}]}) 150 | 151 | (deftest find-route-test 152 | (is (= [:CreateV1alpha1Orange :ReplaceV1alpha1Orange :DeleteV1alpha1Orange :GetV1alpha1Orange :ListV1alpha1Orange] 153 | (internals.client/find-route example-k8s {:kind :Orange}))) 154 | (is (= [:GetV1alpha1Orange] 155 | (internals.client/find-route example-k8s {:kind :Orange 156 | :action :get})))) 157 | 158 | (deftest kubernetes-info-of-route-test 159 | (is (= "v1alpha1" (internals.client/version-of example-k8s :DeleteV1alpha1Orange))) 160 | (is (= :Orange (internals.client/kind-of example-k8s :DeleteV1alpha1Orange))) 161 | (is (= "fruits" (internals.client/group-of example-k8s :DeleteV1alpha1Orange)))) 162 | 163 | (deftest core-versions-test 164 | (is (match? [{:name "" 165 | :versions [{:groupVersion "v1" :version "v1"}] 166 | :preferredVersion {:groupVersion "v1" :version "v1"}}] 167 | (internals.client/core-versions {:kubernetes-api.core/core-api-versions {:versions ["v1"]}})))) 168 | 169 | (deftest all-versions-test 170 | (is (match? (m/in-any-order [{:preferredVersion {:groupVersion "v1" :version "v1"}} 171 | {:preferredVersion {:groupVersion "apps/v1alpha1" :version "v1alpha1"}}]) 172 | (internals.client/all-versions {:kubernetes-api.core/core-api-versions {:versions ["v1"]} 173 | :kubernetes-api.core/api-group-list {:groups [{:name "apps" 174 | :versions [{:groupVersion "apps/v1alpha1" :version "v1alpha1"}] 175 | :preferredVersion {:groupVersion "apps/v1alpha1" :version "v1alpha1"}}]}})))) 176 | 177 | (deftest find-preferred-route-test 178 | (is (= :CreateV1alpha2Orange 179 | (internals.client/find-preferred-route 180 | {:kubernetes-api.core/core-api-versions {:versions ["v1"]} 181 | :kubernetes-api.core/api-group-list {:groups [{:name "fruits" 182 | :versions [{:groupVersion "fruits/v1alpha1" :version "v1alpha1"} 183 | {:groupVersion "fruits/v1alpha2" :version "v1alpha2"}] 184 | :preferredVersion {:groupVersion "apps/v1alpha2" :version "v1alpha2"}}]} 185 | :handlers [{:route-name :CreateV1alpha1Orange 186 | :swagger-definition {:x-kubernetes-action "create" 187 | :x-kubernetes-group-version-kind {:group "fruits" 188 | :version "v1alpha1" 189 | :kind "Orange"}}} 190 | {:route-name :CreateV1alpha2Orange 191 | :swagger-definition {:x-kubernetes-action "create" 192 | :x-kubernetes-group-version-kind {:group "fruits" 193 | :version "v1alpha2" 194 | :kind "Orange"}}}]} 195 | {:kind :Orange 196 | :action :create})))) 197 | 198 | (deftest preffered-version?-test 199 | (let [k8s {:kubernetes-api.core/core-api-versions {:versions ["v1"]} 200 | :kubernetes-api.core/api-group-list {:groups [{:name "fruits" 201 | :versions [{:groupVersion "fruits/v1alpha1" :version "v1alpha1"} 202 | {:groupVersion "fruits/v1alpha2" :version "v1alpha2"}] 203 | :preferredVersion {:groupVersion "apps/v1alpha2" :version "v1alpha2"}}]} 204 | :handlers [{:route-name :CreateV1alpha1Orange 205 | :swagger-definition {:x-kubernetes-action "create" 206 | :x-kubernetes-group-version-kind {:group "fruits" 207 | :version "v1alpha1" 208 | :kind "Orange"}}} 209 | {:route-name :CreateV1alpha2Orange 210 | :swagger-definition {:x-kubernetes-action "create" 211 | :x-kubernetes-group-version-kind {:group "fruits" 212 | :version "v1alpha2" 213 | :kind "Orange"}}}]}] 214 | (is (internals.client/preffered-version? k8s 215 | {:route-name :CreateV1alpha2Orange 216 | :swagger-definition {:x-kubernetes-action "create" 217 | :x-kubernetes-group-version-kind {:group "fruits" 218 | :version "v1alpha2" 219 | :kind "Orange"}}})) 220 | (is (not (internals.client/preffered-version? k8s 221 | {:route-name :CreateV1alpha1Orange 222 | :swagger-definition {:x-kubernetes-action "create" 223 | :x-kubernetes-group-version-kind {:group "fruits" 224 | :version "v1alpha1" 225 | :kind "Orange"}}}))))) 226 | -------------------------------------------------------------------------------- /test/kubernetes_api/internals/martian_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.internals.martian-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [kubernetes-api.internals.martian :as internals.martian] 4 | [martian.core :as martian] 5 | [matcher-combinators.test :refer [thrown-match?]] 6 | [mockfn.macros :refer [providing]]) 7 | (:import [clojure.lang ExceptionInfo])) 8 | 9 | (deftest response-for 10 | (testing "Throws exception from client" 11 | (providing [(martian/response-for 'martian 'testing) 12 | (delay {:kubernetes-api.core/error (ex-info "Test error" {:type :error})})] 13 | (is (thrown-match? ExceptionInfo 14 | {:type :error} 15 | (internals.martian/response-for 'martian 'testing)))))) -------------------------------------------------------------------------------- /test/kubernetes_api/swagger_test.clj: -------------------------------------------------------------------------------- 1 | (ns kubernetes-api.swagger-test 2 | (:require [clojure.test :refer :all] 3 | [kubernetes-api.core :as k8s] 4 | [kubernetes-api.swagger :as swagger] 5 | [matcher-combinators.test :refer [match?]])) 6 | 7 | (deftest remove-watch-endpoints-test 8 | (is (= {:paths {"/foo/bar" 'irrelevant 9 | "/foo/baz" 'irrelevant}} 10 | (swagger/remove-watch-endpoints 11 | {:paths {"/foo/bar" 'irrelevant 12 | "/foo/baz" 'irrelevant 13 | "/foo/watch/bar" 'irrelevant}})))) 14 | 15 | (deftest fix-k8s-verbs-test 16 | (testing "replace post for create" 17 | (is (= {:paths {"/foo/bar" {:get {:x-kubernetes-action "create"}}}} 18 | (swagger/fix-k8s-verb 19 | {:paths {"/foo/bar" {:get {:x-kubernetes-action "post"}}}})))) 20 | (testing "replace watchlist for watch" 21 | (is (= {:paths {"/foo/bar" {:get {:x-kubernetes-action "watch"}}}} 22 | (swagger/fix-k8s-verb 23 | {:paths {"/foo/bar" {:get {:x-kubernetes-action "watchlist"}}}})))) 24 | (testing "replace put for update" 25 | (is (= {:paths {"/foo/bar" {:get {:x-kubernetes-action "update"}}}} 26 | (swagger/fix-k8s-verb 27 | {:paths {"/foo/bar" {:get {:x-kubernetes-action "put"}}}})))) 28 | (testing "leave unaltered the rest" 29 | (doseq [verb ["create" "get" "list" "watch" "update" "patch" "delete" "deletecollection"]] 30 | (testing "replace put for update" 31 | (is (= {:paths {"/foo/bar" {:get {:x-kubernetes-action verb}}}} 32 | (swagger/fix-k8s-verb 33 | {:paths {"/foo/bar" {:get {:x-kubernetes-action verb}}}}))))))) 34 | 35 | (deftest fix-consumes-test 36 | (testing "fixes consumes content types when its */*" 37 | (is (= {:paths {"/foo/bar" {:get {:consumes ["application/json"]}}}} 38 | (swagger/fix-consumes 39 | {:paths {"/foo/bar" {:get {:consumes ["*/*"]}}}})))) 40 | (testing "leave unaltered when its not */*" 41 | (is (= {:paths {"/foo/bar" {:get {:consumes ["application/yaml"]}}}} 42 | (swagger/fix-consumes 43 | {:paths {"/foo/bar" {:get {:consumes ["application/yaml"]}}}}))))) 44 | 45 | (deftest add-patch-routes-test 46 | (testing "for each patch operation, add all patch strategies" 47 | (is (match? {:paths {"/foo/bar" {:patch/json {:parameters [{:name "body" 48 | :in "body" 49 | :schema swagger/rfc6902-json-schema}], 50 | :operationId "PatchCoreV1ResourceJsonPatch" 51 | :consumes ["application/json-patch+json"] 52 | :x-kubernetes-action "patch/json"} 53 | :patch/json-merge {:parameters [{:name "body" 54 | :in "body" 55 | :schema 'schema}] 56 | :operationId "PatchCoreV1ResourceJsonMerge" 57 | :consumes ["application/merge-patch+json"] 58 | :x-kubernetes-action "patch/json-merge"} 59 | :patch/strategic {:parameters [{:name "body" 60 | :in "body" 61 | :schema 'schema}] 62 | :operationId "PatchCoreV1ResourceStrategicMerge" 63 | :consumes ["application/strategic-merge-patch+json"] 64 | :x-kubernetes-action "patch/strategic"} 65 | :apply/server {:parameters [{:name "body" 66 | :in "body" 67 | :schema 'schema}] 68 | :operationId "PatchCoreV1ResourceApplyServerSide" 69 | :consumes ["application/apply-patch+yaml"] 70 | :x-kubernetes-action "apply/server"}}}} 71 | (swagger/add-patch-routes 72 | {:paths {"/foo/bar" {:put {:parameters [{:name "body" 73 | :in "body" 74 | :schema 'schema}] 75 | :x-kubernetes-action "update"} 76 | :patch {:operationId "PatchCoreV1Resource" 77 | :parameters [{:name "body" 78 | :in "body" 79 | :schema 'broken}]}}}}))))) 80 | 81 | (deftest add-summary-test 82 | (testing "copies description to summary if summary doesnt exists" 83 | (is (= {:paths {"/foo/bar" {:get {:description "foo" 84 | :summary "foo"}}}} 85 | (swagger/add-summary 86 | {:paths {"/foo/bar" {:get {:description "foo"}}}}))) 87 | (is (= {:paths {"/foo/bar" {:get {:summary "foo" :description "foo bar baz"}}}} 88 | (swagger/add-summary 89 | {:paths {"/foo/bar" {:get {:summary "foo" 90 | :description "foo bar baz"}}}}))))) 91 | 92 | (deftest add-some-routes-test 93 | (is (= {:definitions {:Book {:type "object", :properties {:name {:type "string"}}}, 94 | :Movie {:type "object", :properties {:name {:type "string"}}}}, 95 | :paths {"/books/{id}" {:get {:response-schemas {"200" {:$ref "#/definitions/Book"}}}}, 96 | "/movies/{id}" {:get {:response-schemas {"200" {:$ref "#/definitions/Movie"}}}}}} 97 | (swagger/add-some-routes {:definitions {:Book {:type "object" 98 | :properties {:name {:type "string"}}}} 99 | :paths {"/books/{id}" {:get {:response-schemas {"200" {:$ref "#/definitions/Book"}}}}}} 100 | {:Movie {:type "object" 101 | :properties {:name {:type "string"}}}} 102 | {"/movies/{id}" {:get {:response-schemas {"200" {:$ref "#/definitions/Movie"}}}}})))) 103 | 104 | (deftest group-version-test 105 | (testing "applies to string" 106 | (is (= {:group "apps" :version "v1"} 107 | (swagger/group-version "apps/v1"))) 108 | (is (= {:group "apps"} 109 | (swagger/group-version "apps"))) 110 | (is (= {:group "" :version "v1"} 111 | (swagger/group-version "core/v1")))) 112 | (testing "applies to keyword" 113 | (is (= {:group "apps" :version "v1"} 114 | (swagger/group-version :apps/v1))) 115 | (is (= {:group "apps"} 116 | (swagger/group-version :apps))) 117 | (is (= {:group "" :version "v1"} 118 | (swagger/group-version :core/v1))))) 119 | 120 | (deftest from-group-version?-test 121 | (testing "applies to string" 122 | (is (swagger/from-group-version? "apps/v1" "/apis/apps/v1/namespaces/:namespace/deployments")) 123 | (is (swagger/from-group-version? "apps" "/apis/apps/v1/namespaces/:namespace/deployments")) 124 | (is (swagger/from-group-version? "core/v1" "/api/v1/namespaces/:namespace/pods"))) 125 | (testing "applies to keyword" 126 | (is (swagger/from-group-version? :apps/v1 "/apis/apps/v1/namespaces/:namespace/deployments")) 127 | (is (swagger/from-group-version? :apps "/apis/apps/v1/namespaces/:namespace/deployments")) 128 | (is (swagger/from-group-version? :core/v1 "/api/v1/namespaces/:namespace/pods")))) 129 | 130 | (deftest from-apis-test 131 | (testing "returns the paths from the api version specified and the default paths" 132 | (is (= {"/apis/" {} 133 | "/api/" {} 134 | "/apis/foo.bar/v1" {}} 135 | (swagger/from-apis ["foo.bar/v1"] 136 | {"/apis/" {} 137 | "/api/" {} 138 | "/apis/foo/bar" {} 139 | "/apis/foo.bar/v1" {}}))) 140 | (is (= {"/apis/" {} 141 | "/api/" {} 142 | "/apis/foo/bar" {} 143 | "/apis/foo.bar/v1" {}} 144 | (swagger/from-apis ["foo.bar/v1", "foo"] 145 | {"/apis/" {} 146 | "/api/" {} 147 | "/apis/foo/bar" {} 148 | "/apis/foo.bar/v1" {}}))) 149 | (is (= {"/apis/" {} 150 | "/api/" {}} 151 | (swagger/from-apis ["foo.bar/v2"] 152 | {"/apis/" {} 153 | "/api/" {} 154 | "/apis/foo/bar" {} 155 | "/apis/foo.bar/v1" {}}))))) 156 | 157 | (deftest filter-paths-test 158 | (testing "filter the paths for the api and version specified" 159 | (is (= {:paths {"/apis/" {} 160 | "/api/" {} 161 | "/apis/foo.bar/v1" {}}} 162 | (swagger/filter-paths {:paths {"/apis/" {} 163 | "/api/" {} 164 | "/apis/foo/bar" {} 165 | "/apis/foo.bar/v1" {}}} 166 | ["foo.bar/v1"]))) 167 | (is (= {:paths {"/apis/" {} 168 | "/api/" {} 169 | "/apis/foo/bar" {} 170 | "/apis/foo.bar/v1" {}}} 171 | (swagger/filter-paths {:paths {"/apis/" {} 172 | "/api/" {} 173 | "/apis/foo/bar" {} 174 | "/apis/foo.bar/v1" {}}} 175 | ["foo.bar/v1" "foo"])))) 176 | 177 | (testing "do not update paths if api or version not found" 178 | (is (= {:paths {"/apis/" {} 179 | "/api/" {}}} 180 | (swagger/filter-paths {:paths {"/apis/" {} 181 | "/api/" {} 182 | "/apis/foo/bar" {} 183 | "/apis/foo.bar/v1" {}}} 184 | ["foo.bar/v2"])))) 185 | 186 | (testing "returns the default paths if api or version is missing" 187 | (is (= {:paths {"/apis/" {} 188 | "/api/" {}}} 189 | (swagger/filter-paths {:paths {"/apis/" {} 190 | "/api/" {} 191 | "/apis/foo/bar" {} 192 | "/apis/foo.bar/v1" {}}} [])))) 193 | 194 | (testing "Returns default apis" 195 | (is (match? {:paths {"/api/v1/configmaps" {} 196 | "/apis/apps/v1/deployments" {} 197 | "/apis/apps/" {} 198 | "/apis/apps/v1/" {} 199 | "/logs/" {} 200 | "/openid/v1/jwks/" {} 201 | "/version/" {}}} 202 | (-> (swagger/parse-swagger-file "test_swagger.json") 203 | (swagger/filter-paths k8s/default-apis)))))) 204 | --------------------------------------------------------------------------------