├── .gitignore ├── LICENSE ├── README.md ├── dev ├── playground.clj └── user.clj ├── images └── 1.png ├── project.clj ├── resources └── swagger │ ├── apps_v1beta1.json │ ├── extensions_v1beta1.json │ └── v1.json └── src └── rube ├── api └── swagger.clj ├── core.clj ├── lens.clj ├── request.clj └── state.clj /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | *.iml 13 | 14 | doc 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Blake Miller 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/blak3mill3r/keenest-rube/blob/master/images/1.png) 2 | 3 | ## `[keenest-rube "0.1.0-alpha0"]` 4 | 5 | [![Clojars Project](https://img.shields.io/clojars/v/keenest-rube.svg)](https://clojars.org/keenest-rube) 6 | 7 | ## A Clojure tool for Kubernetes operations 8 | 9 | > * Cluster state is a value in an atom, the API calls are abstracted away 10 | 11 | > * Manage your resources from the comfort of your Clojure REPL 12 | 13 | > * Or, use it to build a Kubernetes abstraction... 14 | 15 | #### Demo? 16 | 17 | > ###### This one time, when I had completed [the setup](#setup) already... 18 | 19 | ```clojure 20 | ;; I started poking around... 21 | 22 | (count @pods) ;; => 0 23 | (count @replicationcontrollers) ;; => 0 24 | (count @services) ;; => 0 25 | 26 | ``` 27 | 28 | 29 | ######   *;;*      :-1: 30 | 31 | 32 | ```clojure 33 | ;; and then I was all, like 34 | (swap! pods assoc :my-awesome-podz 35 | {:metadata 36 | {:labels {:app "bunk" :special "false"} :namespace "playground" :name "my-awesome-podz"} 37 | :spec 38 | {:containers 39 | [{:name 40 | "two-peas" 41 | :env 42 | [{:name "REDIS_URL" :value "prolly.xwykgm.ng.0001.use1.cache.amazonaws.com:6379"}] 43 | :ports 44 | [{:containerPort 420, :protocol "TCP"}] 45 | :image 46 | "1337.dkr.ecr.us-east-1.amazonaws.com/two-peas:v1.0"}] 47 | :restartPolicy "Always", 48 | :terminationGracePeriodSeconds 30}}) 49 | ``` 50 | 51 | ######   *;;*      `=>` :fire: 52 | 53 | ```clojure 54 | ;; (or if we're being optimistic, a new value for the pods atom) 55 | 56 | ;; and after a moment, a running pod: 57 | (-> @pods 58 | :my-awesome-podz 59 | :status 60 | :containerStatuses 61 | first 62 | :ready) ;; => true 63 | 64 | ;; and similarly for other resources... 65 | ;; the Clojure data matches the JSON of the k8s API 66 | ;; which is good, because there are no docs (yet) 67 | ``` 68 | 69 | #### Design 70 | > * Kubernetes' [API](https://kubernetes.io/docs/api-reference/v1.8/) is an uncommonly good one, it's very consistent 71 | > * It bends the REST rules and supports streaming updates to a client 72 | > * given a `WATCH` request, it will keep the response body open, and continue to send lines of JSON 73 | 74 | > * On initialization, `keenest-rube` `GET`s a (versioned) list of each kind of resource, 75 | > initializes the atom, and then starts tailing a `WATCH` request starting from that version 76 | > * this ensures that the atom is kept closely in sync with Kubernetes (few hundred ms, tops) 77 | > * this uses [`aleph`](https://github.com/ztellman/aleph) behind the scenes 78 | 79 | > * Reading the state of the cluster is as easy as dereferencing it... 80 | 81 | > * The [abstraction over mutations](src/rube/lens.clj) is provided by a ["derived atom" from `lentes`](http://funcool.github.io/lentes/latest/#working-with-atoms) for each kind of resource 82 | > * the `pods` atom in the demo, for example 83 | > * the value in it is a map (by name) of all the pods in the namespace 84 | > * these atoms make an API call as a side-effect of an update (by diffing) 85 | > * You `swap!` the atom, `keenest-rube` does `PUT`, `DELETE`, and `POST` 86 | > * All API errors throw, and the state of the atom is updated *only* using data from Kubernetes. 87 | > * *Your own updates to it are more like suggestions...* 88 | > * Only API responses from mutations, and the data from a `WATCH` stream, update the atom 89 | > * Multiple resource mutations in a single `swap!` are [explicitly disallowed](src/rube/lens.clj#L83) 90 | > * Because you'll be wanting to see the error message if a mutation fails 91 | 92 | ### Setup 93 | 94 | > :point_right:      If you just want to try it out, 95 | 96 | >            just clone this repo, launch a repl, and look at [`dev/user.clj`](dev/user.clj) 97 | 98 | If you want to use it in your own project, you'll want something to manage the state... 99 | 100 | Supposing we want to use [`mount`](https://github.com/tolitius/mount) and [`leiningen`](https://leiningen.org/): 101 | 102 | > In `project.clj` 103 | 104 | ```clojure 105 | :dependencies [[keenest-rube "0.1.0-alpha0"]] ;; the "alpha" is for realz 106 | :profiles {:dev {:dependencies [[mount "0.1.11"]]}} 107 | ``` 108 | 109 | > and in `src/your/playground.clj` 110 | 111 | ```clojure 112 | (ns your.playground 113 | (:require 114 | [mount.core :as mount :refer [defstate]] 115 | [rube.core :as k])) 116 | 117 | (defstate kube 118 | :start (k/intern-resources ;; this interns a var for each k8s resource 119 | (k/cluster 120 | {:server "http://localhost:8080" ;; kubectl proxy (or whatever) 121 | :namespace "playground"})) ;; not production! (yet) 122 | :stop (k/disconnect! kube)) 123 | ``` 124 | 125 | ### Disclaimer 126 | 127 | > This is `alpha` for a reason. It has not been thoroughly tested, it may misbehave, and the API may change. 128 | 129 | > Use it at your own risk. 130 | 131 | > That being said, it is restricted to operating within a k8s *namespace* 132 | 133 | > so it should be mostly harmless... 134 | 135 | ### Is it any good? 136 | 137 | > Yes. 138 | 139 | ### Contributing 140 | 141 | > Fork it, send me a pull request! 142 | 143 | ### License 144 | 145 | > [MIT](LICENSE) 146 | 147 | ### Similar work 148 | 149 | https://github.com/nubank/clj-kubernetes-api 150 | 151 | This library is quite different: 152 | 153 | It's solid, much more low-level, providing Clojure helpers for each API call, generated from the official Swagger specs. It's also more complete than `keenest-rube`, which is young and has limitations. There's no reason you can't use them side by side, though. 154 | 155 | Also I found it to be a valuable resource, and borrowed a couple of helper functions, so with many thanks to the author(s) of that library, I'll call this a derivative work. -------------------------------------------------------------------------------- /dev/playground.clj: -------------------------------------------------------------------------------- 1 | (ns playground 2 | (:require 3 | [mount.core :as mount :refer [defstate]] 4 | [rube.core :as k])) 5 | 6 | (defstate kube 7 | :start (k/intern-resources 8 | (k/cluster 9 | {:server "http://localhost:8080" 10 | :namespace "playground"})) 11 | :stop (k/disconnect! kube)) 12 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [mount.core :refer [start stop]] 3 | [clojure.tools.namespace.repl :refer [refresh]])) 4 | 5 | (defn go [] (start) :ready ) 6 | (defn reset [] (stop) (refresh :after 'user/go) ) 7 | 8 | #_(go) 9 | #_(reset) 10 | 11 | ;; for this to work, you have to already have a Kubernetes namespace called "playground" 12 | ;; and you're running kubectl proxy --port=8080 13 | ;; (or you have modified playground.clj) 14 | 15 | #_(-> @pods count) ;; => 0, presumably 16 | 17 | #_(swap! pods assoc :my-awesome-podz 18 | {:metadata 19 | {:labels {:app "bunk" :special "false"} :namespace "playground" :name "my-awesome-podz"} 20 | :spec 21 | {:containers 22 | [{:name 23 | "two-peas" 24 | :env 25 | [{:name "REDIS_URL" :value "prolly.xwykgm.ng.0001.use1.cache.amazonaws.com:6379"}] 26 | :ports 27 | [{:containerPort 420, :protocol "TCP"}] 28 | :image 29 | "1337.dkr.ecr.us-east-1.amazonaws.com/two-peas:v1.0"}] 30 | :restartPolicy "Always", 31 | :terminationGracePeriodSeconds 30}}) 32 | 33 | #_(-> @pods 34 | :my-awesome-podz 35 | :status 36 | :containerStatuses 37 | first 38 | :state 39 | :waiting 40 | :message) 41 | ;; => "rpc error: code = 2 desc = Error response from daemon: {\"message\":\"denied: Could not resolve registry id from host 1337.dkr.ecr.us-east-1.amazonaws.com\"}" 42 | 43 | ;; because there is no such registry 44 | ;; and no such container 45 | -------------------------------------------------------------------------------- /images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blak3mill3r/keenest-rube/bfd1ac9ca36cef4055bb49f108613871ce4c0b18/images/1.png -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject keenest-rube "0.1.0-alpha0" 2 | :description "The state of a Kubernetes cluster, abstracted as a value in a Clojure atom." 3 | :url "https://github.com/blak3mill3r/keenest-rube" 4 | :license {:name "MIT" 5 | :url "https://github.com/blak3mill3r/keenest-rube/blob/master/LICENSE"} 6 | 7 | :dependencies [[org.clojure/clojure "1.9.0-alpha17"] 8 | [org.clojure/core.async "0.3.442"] 9 | [org.clojure/data.json "0.2.6"] 10 | [aleph "0.4.4-alpha4"] 11 | [funcool/lentes "1.2.0"] 12 | [com.rpl/specter "1.0.4"] 13 | [org.clojure/core.match "0.3.0-alpha5"]] 14 | 15 | :profiles {:dev 16 | {:dependencies [[org.clojure/tools.namespace "0.2.11"] 17 | [mount "0.1.11"]] 18 | :repl-options {:init (println (char 7))} 19 | :plugins [] 20 | :source-paths ["dev" "src"]}}) 21 | -------------------------------------------------------------------------------- /src/rube/api/swagger.clj: -------------------------------------------------------------------------------- 1 | (ns rube.api.swagger 2 | "" 3 | (:require [clojure.string :as str] 4 | [clojure.java.io :as io] 5 | [clojure.data.json :as json] 6 | [com.rpl.specter :refer :all])) 7 | 8 | (def v1-resource-names 9 | #{ "configmaps" "endpoints" "events" "limitranges" "persistentvolumeclaims" "pods" "podtemplates" "replicationcontrollers" "resourcequotas" "secrets" "serviceaccounts" "services" }) 10 | 11 | (def apps-v1-beta1-resource-names 12 | #{ "deployments" "statefulsets" }) 13 | 14 | (def extensions-v1-beta1-resource-names 15 | #{ "daemonsets" "deployments" "horizontalpodautoscalers" "ingresses" "jobs" "networkpolicies" "replicasets" }) 16 | 17 | (def supported-resources 18 | #{"replicationcontrollers" 19 | "pods" 20 | "services" 21 | "deployments" 22 | "statefulsets" 23 | "persistentvolumeclaims" 24 | "replicasets" 25 | ;; "jobs" ;; broken 26 | }) 27 | 28 | (defn prefix [resource] 29 | (condp get (name resource) 30 | v1-resource-names "/api/v1" 31 | apps-v1-beta1-resource-names "/apis/apps/v1beta1" 32 | extensions-v1-beta1-resource-names "/apis/extensions/v1beta1" 33 | (throw (ex-info "Unknown resource" {:resource resource})))) 34 | 35 | (defn path-pattern 36 | [resource-kind] 37 | (str (prefix resource-kind)"/namespaces/{namespace}/"(name resource-kind))) 38 | 39 | (defn path-pattern-one 40 | [resource-kind] 41 | (str (prefix resource-kind)"/namespaces/{namespace}/"(name resource-kind)"/{name}")) 42 | 43 | (def success? #{200 201 202}) 44 | 45 | ;; copied from clj-kubernetes-api 46 | #_(defn swagger-spec [version] 47 | (-> (str "swagger/" version ".json") 48 | io/resource 49 | slurp 50 | (json/read-str :key-fn keyword))) 51 | 52 | #_(def zapis (:apis (swagger-spec "v1"))) 53 | 54 | #_(doseq [{:keys [path description operations]} zapis 55 | :when (and (re-matches #".*watch.*" path) 56 | (re-matches #".*namespace.*" path) 57 | (not (re-matches #".*\{name\}.*" path)))] 58 | (println path)) 59 | -------------------------------------------------------------------------------- /src/rube/core.clj: -------------------------------------------------------------------------------- 1 | (ns rube.core 2 | (:require [clojure.string :as str] 3 | [clojure.core.async :as a :refer [ @kube-atom :kill-ch)] 25 | (do (println "Closing kill chan") (a/close! ch)) 26 | (println "Hmm, there's no kill chan?")) 27 | (reset! kube-atom {:state :disconnected})) 28 | 29 | (defn intern-resources 30 | "Intern in the current namespace a symbol named after each kind of k8s resource. 31 | These are lenses with side-effects including making requests to the k8s API." 32 | ([kube-atom] 33 | (intern-resources kube-atom *ns*)) 34 | ([kube-atom ns] 35 | (doseq [resource-name api/supported-resources :let [lens (resource-lens resource-name kube-atom)]] 36 | (intern ns (symbol resource-name) lens)) 37 | kube-atom)) 38 | 39 | (defn context 40 | "Helper function to create a kube context" 41 | [server namespace & {:keys [username password]}] 42 | {:server server 43 | :namespace namespace 44 | :username username 45 | :password password}) 46 | 47 | (defn- state-watch-loop-init! 48 | "Start updating the state with a k8s watch stream. `body` is the response with the current list of items and the `resourceVersion`." 49 | [kube-atom resource-name {{v :resourceVersion} :metadata items :items :as body} kill-ch] 50 | (swap! kube-atom (update-from-snapshot resource-name items)) 51 | (let [ctx (:context @kube-atom)] 52 | (state-watch-loop! kube-atom 53 | resource-name 54 | (watch-request ctx v {:method :get :path (api/path-pattern resource-name) :kill-ch kill-ch}) 55 | kill-ch)) 56 | :connected) 57 | 58 | (defn- watch-init! 59 | "Do an initial GET request to list the existing resources of the given kind, then start tailing the watch stream." 60 | [kube-atom resource-name kill-ch] 61 | (let [resource-name (keyword resource-name) 62 | ctx (:context @kube-atom) 63 | {:keys [body status] :as response} ( wait max-wait) :gave-up (recur (* 2 wait))))))) 76 | 77 | (defn- state-watch-loop! [kube-atom resource-name watch-ch kill-ch] 78 | (a/thread 79 | (loop [] 80 | (let [{:keys [type object] :as msg} ( object :metadata :name keyword) type object)) 86 | (recur))))))) 87 | -------------------------------------------------------------------------------- /src/rube/lens.clj: -------------------------------------------------------------------------------- 1 | (ns rube.lens 2 | "Make stateful k8s API calls based on changes to local state." 3 | (:require [lentes.core :as l] 4 | [clojure.data :as data] 5 | [clojure.core.match :refer [match]] 6 | [clojure.core.async :refer [ (l/lens (keyword resource-name) #(apply resource-setter (keyword resource-name) %&)) 18 | (l/derive kube-state))) 19 | 20 | (defn- resource-update! 21 | "Try to manipulate k8s state via an API call, and either throw or return (f resource-state body)." 22 | [resource-state context request-options f] 23 | (let [{:keys [status body] :as response} 24 | ( next-resource-state 32 | (resource-update! context 33 | {:method :put :path (api/path-pattern-one k) 34 | :params {:namespace (:namespace context) :name (name n)} 35 | :body (get next-resource-state n)} 36 | #(assoc-in % [k n] %2)))) 37 | 38 | (defn- create-one! 39 | "Do a POST to create a resource of kind `k` with name `n`, using its value from next-resource-state." 40 | [next-resource-state context k n] 41 | (-> next-resource-state 42 | (resource-update! context 43 | {:method :post :path (api/path-pattern k) 44 | :params {:namespace (:namespace context)} 45 | :body (get next-resource-state n)} 46 | #(assoc-in % [k n] %2)))) 47 | 48 | (defn- delete-one! 49 | "Do a DELETE request on a resource of kind `k` with name `n`." 50 | [resource-state context k n] 51 | (-> resource-state 52 | (resource-update! context 53 | {:method :delete 54 | :path (api/path-pattern-one k) 55 | :params {:namespace (:namespace context) 56 | :name (name n)}} 57 | (fn [state _] (dissoc state n))))) 58 | 59 | (defn- resource-setter 60 | "Takes a resource, a kube-state, and a resource-state transition function `f`, 61 | diffs the resource-state before & after the application of `f`, and conditionally makes stateful API calls. 62 | Returns a next-kube-state (or throws on API errors). 63 | By design, it only allows updating, creating, or deleting one resource at a time." 64 | [k {:as kube-state :keys [context]} f] 65 | (let [resource-state (k kube-state) 66 | next-resource-state (k (update kube-state k f)) 67 | 68 | [was shall-be still-is] (data/diff resource-state next-resource-state) 69 | 70 | [[resource-name _]] (seq shall-be) 71 | [[delete-name _]] (seq was) 72 | pre-existing? (if (get still-is resource-name) 1 0)] 73 | 74 | (->> 75 | (match [(count was) (count shall-be) pre-existing?] 76 | 77 | [ 0 0 _ ] resource-state 78 | [ 0 1 1 ] (-> next-resource-state (replace-one! context k resource-name)) 79 | [ 0 1 0 ] (-> next-resource-state (create-one! context k resource-name)) 80 | [ 1 0 _ ] (-> resource-state (delete-one! context k delete-name)) 81 | 82 | :else 83 | (if (= 1 (count (merge was shall-be))) 84 | (-> next-resource-state (replace-one! context k resource-name)) 85 | (throw (ex-info "Modifying multiple resources in one mutation is not supported." {:names (keys (merge was shall-be))})))) 86 | 87 | (assoc kube-state k)))) 88 | -------------------------------------------------------------------------------- /src/rube/request.clj: -------------------------------------------------------------------------------- 1 | (ns rube.request 2 | (:require 3 | [clojure.data.json :as json] 4 | [clojure.string :as str] 5 | 6 | [byte-streams :as bs] 7 | [manifold.stream :as s] 8 | [manifold.deferred :as d] 9 | [aleph.http :as http] 10 | [clojure.core.async :as a :refer [! req (d/chain :body #(s/filter identity %) #(s/map bs/to-string %)) deref (s/connect raw-ch)) 41 | (a/thread 42 | (loop [buf ""] 43 | (let [[chunk p] (a/alts!! [raw-ch kill-ch])] 44 | (when-not (= p kill-ch) 45 | ;; if the raw channel closes, close the return channel as well (the whole thing needs to be retried) 46 | (if (nil? chunk) 47 | (close! return-ch) 48 | (if-let [i (str/index-of chunk "\n")] 49 | (do 50 | (a/put! return-ch 51 | (-> (subs (str buf chunk) 0 (+ (count buf) i 1)) 52 | (json/read-str :key-fn keyword))) 53 | (recur (subs chunk (inc i)))) 54 | (recur (str buf chunk)))))))))) 55 | 56 | (def default-connection-pool 57 | (memoize 58 | (fn [watch?] 59 | (http/connection-pool 60 | {:connections-per-host 128 61 | :connection-options {:raw-stream? watch?}})))) 62 | 63 | (defn request [{:keys [username password namespace] :as ctx} {:keys [method path params query body kill-ch pool] :as req-opt}] 64 | (let [;; basic-auth (token username password) 65 | params (merge {:namespace namespace} params) 66 | watch? (:watch query) 67 | return-ch (chan) 68 | 69 | req (http/request 70 | {:query-params query 71 | :body (json/write-str body) 72 | :headers {"Content-Type" (content-type method)} 73 | :pool (or pool (default-connection-pool watch?)) 74 | 75 | :method method 76 | :url (url ctx path params) 77 | 78 | :pool-timeout 16000 79 | :connection-timeout 16000 80 | :request-timeout 16000})] 81 | 82 | (if watch? 83 | 84 | ;; continuously put parsed objects on the return channel as they arrive 85 | (tail-watch-request req return-ch kill-ch) 86 | 87 | ;; put the parsed body on the return channel 88 | (-> req 89 | (d/chain (fn [z] (update z :body #(-> % bs/to-string (json/read-str :key-fn keyword))))) 90 | (d/catch (fn [e] 91 | (if-let [error-response (ex-data e)] 92 | (-> error-response (update :body (comp #(json/read-str % :key-fn keyword) bs/to-string))) 93 | e))) 94 | (s/connect return-ch))) 95 | 96 | return-ch)) 97 | 98 | (defn watch-request [ctx resource-version request-params] 99 | (request ctx (update request-params :query assoc 100 | :watch true 101 | :resourceVersion resource-version))) 102 | -------------------------------------------------------------------------------- /src/rube/state.clj: -------------------------------------------------------------------------------- 1 | (ns rube.state 2 | "Pure functions for updating the kube state") 3 | 4 | (defn update-from-snapshot 5 | "Incorporate `items`, a snapshot of the existing set of resources of a given kind." 6 | [resource-name items] 7 | #(assoc % (keyword resource-name) 8 | (into {} (for [{{name :name} :metadata :as object} items] 9 | [(keyword name) object])))) 10 | 11 | (defn update-from-event 12 | "Map from k8s watch-stream messages -> state transition functions for the kube atom." 13 | [resource-name name type object] 14 | (condp get type 15 | #{ "DELETED" } #(update % resource-name dissoc name) 16 | #{ "ADDED" "MODIFIED" } #(update % resource-name assoc name object) 17 | identity)) 18 | --------------------------------------------------------------------------------