├── .dockerignore ├── spec ├── spec_helper.cr ├── operator_spec.cr ├── import_crd_spec.cr ├── crd_spec.cr └── fixtures │ └── nats.yaml ├── .gitignore ├── .editorconfig ├── examples ├── Dockerfile.florps ├── mutating_webhooks │ ├── shard.yml │ ├── Dockerfile │ ├── src │ │ ├── base64_converter.cr │ │ ├── json_patch.cr │ │ ├── main.cr │ │ ├── webhook.cr │ │ └── admission_review.cr │ └── config.yaml ├── florps.yaml ├── serviceapps.yaml ├── crd-florps.yaml ├── crd-serviceapps.yaml ├── florp_controller.cr └── svcapp_controller.cr ├── shard.yml ├── LICENSE ├── src ├── import_crd.cr ├── serializable.cr ├── crd.cr └── kubernetes.cr └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | shard.lock 7 | -------------------------------------------------------------------------------- /spec/operator_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Operator do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /examples/Dockerfile.florps: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.1.1-alpine 2 | 3 | COPY . /florps/ 4 | WORKDIR /florps/ 5 | 6 | RUN shards && crystal build examples/florp_controller.cr 7 | 8 | CMD ["./florps_controller"] 9 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: kubernetes 2 | version: 0.1.0 3 | 4 | authors: 5 | - Jamie Gaskins 6 | 7 | targets: 8 | florps: 9 | main: examples/florps_controller.cr 10 | 11 | dependencies: 12 | db: 13 | github: crystal-lang/crystal-db 14 | 15 | crystal: 1.1.0 16 | 17 | license: MIT 18 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/shard.yml: -------------------------------------------------------------------------------- 1 | name: kubernetes 2 | version: 0.1.0 3 | 4 | authors: 5 | - Jamie Gaskins 6 | 7 | targets: 8 | mutating_webhooks: 9 | main: src/main.cr 10 | 11 | dependencies: 12 | kubernetes: 13 | github: jgaskins/kubernetes 14 | 15 | crystal: 1.7.2 16 | 17 | license: MIT 18 | -------------------------------------------------------------------------------- /examples/florps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: jgaskins.wtf/v1 3 | kind: Florp 4 | metadata: 5 | name: my-florp 6 | spec: 7 | name: "omg florps!" 8 | id: "omg-florps" 9 | count: 2 10 | --- 11 | apiVersion: jgaskins.wtf/v1 12 | kind: Florp 13 | metadata: 14 | name: new-florp 15 | spec: 16 | name: "hello" 17 | id: "my-new-florp" 18 | count: 1 19 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 84codes/crystal:latest-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY shard.yml /app/ 6 | RUN shards 7 | 8 | COPY src/ /app/src/ 9 | RUN shards build mutating_webhooks --static --release 10 | 11 | FROM scratch 12 | 13 | COPY --from=builder /app/bin/mutating_webhooks / 14 | 15 | CMD ["/mutating_webhooks"] 16 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/src/base64_converter.cr: -------------------------------------------------------------------------------- 1 | require "base64" 2 | 3 | module Base64Converter(T) 4 | extend self 5 | 6 | def from_json(json : JSON::PullParser) 7 | T.from_json Base64.decode_string(json.read_string) 8 | end 9 | 10 | def to_json(value : T, json : JSON::Builder) 11 | Base64.strict_encode(value.to_json).to_json json 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /examples/serviceapps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: example.com/v1 3 | kind: ServiceApp 4 | metadata: 5 | name: my-svcapp 6 | spec: 7 | domain: "app.omg.lol" # Applied to the Ingress 8 | command: ["sleep", "10000"] # The command for the Deployment pods to run 9 | image: "busybox:latest" # Image for the Deployment to use 10 | replicas: 1 # Number of pods to run for the Deployment 11 | -------------------------------------------------------------------------------- /spec/import_crd_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | require "../src/kubernetes" 4 | 5 | # Defines a NATSCluster, a NATSStream, and a NATSConsumer 6 | Kubernetes.import_crd "spec/fixtures/nats.yaml" 7 | 8 | describe "import_crd" do 9 | it "defines the constants" do 10 | NATSCluster.should_not eq nil 11 | NATSStream.should_not eq nil 12 | NATSConsumer.should_not eq nil 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/src/json_patch.cr: -------------------------------------------------------------------------------- 1 | require "kubernetes" 2 | require "json" 3 | 4 | struct JSONPatch 5 | include Kubernetes::Serializable 6 | 7 | field op : Operation 8 | field path : String 9 | field value : JSON::Any 10 | 11 | def self.new(*, op : Operation, path, value : JSON::Any::Type) 12 | new op: op, path: path, value: JSON::Any.new(value) 13 | end 14 | 15 | def initialize(*, @op, @path, @value) 16 | end 17 | 18 | enum Operation 19 | ADD 20 | REMOVE 21 | REPLACE 22 | COPY 23 | MOVE 24 | TEST 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /examples/crd-florps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: florps.jgaskins.wtf 6 | spec: 7 | group: jgaskins.wtf 8 | versions: 9 | - name: v1 10 | served: true 11 | storage: true 12 | schema: 13 | openAPIV3Schema: 14 | type: object 15 | properties: 16 | spec: 17 | type: object 18 | properties: 19 | name: 20 | type: string 21 | id: 22 | type: string 23 | count: 24 | type: integer 25 | scope: Namespaced 26 | names: 27 | plural: florps 28 | singular: florp 29 | kind: Florp 30 | shortNames: 31 | - fl 32 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/src/main.cr: -------------------------------------------------------------------------------- 1 | require "openssl" 2 | require "http" 3 | require "log" 4 | 5 | require "./webhook" 6 | 7 | Log.setup_from_env 8 | 9 | log = Log.for("app") 10 | app = WebhookHandler.new(log) 11 | 12 | http = HTTP::Server.new([ 13 | HTTP::LogHandler.new, 14 | HTTP::CompressHandler.new, 15 | app, 16 | ]) 17 | Signal::TERM.trap { http.close } 18 | port = ENV.fetch("PORT", "3000").to_i 19 | 20 | tls = OpenSSL::SSL::Context::Server.new 21 | tls.ca_certificates = "/certs/ca.crt" 22 | tls.certificate_chain = "/certs/tls.crt" 23 | tls.private_key = "/certs/tls.key" 24 | 25 | http.bind_tls "0.0.0.0", port, context: tls 26 | log.info { "Listening on port #{port}..." } 27 | http.listen 28 | 29 | # Allow any in-flight requests to finish up before exiting 30 | while app.handling_requests? 31 | sleep 1.second 32 | end 33 | -------------------------------------------------------------------------------- /examples/crd-serviceapps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: serviceapps.example.com 6 | spec: 7 | group: example.com 8 | versions: 9 | - name: v1 10 | served: true 11 | storage: true 12 | schema: 13 | openAPIV3Schema: 14 | type: object 15 | properties: 16 | spec: 17 | type: object 18 | properties: 19 | domain: 20 | type: string 21 | command: 22 | type: array 23 | items: 24 | type: string 25 | minItems: 1 26 | image: 27 | type: string 28 | replicas: 29 | type: integer 30 | scope: Namespaced 31 | names: 32 | plural: serviceapps 33 | singular: serviceapp 34 | kind: ServiceApp 35 | shortNames: 36 | - svcapp 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Jamie Gaskins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/import_crd.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require "uuid/yaml" 3 | require "./serializable" 4 | 5 | require "./crd" 6 | 7 | begin 8 | File.read(ARGV[0]).split(/\n---\n/).each do |yaml| 9 | crd = Kubernetes::CRD.from_yaml(yaml) 10 | 11 | version = crd.spec.versions.find { |v| v.storage }.not_nil! 12 | spec = version.schema.open_api_v3_schema.properties.spec 13 | properties = spec.properties 14 | 15 | description = String.build do |str| 16 | spec.description.try &.each_line do |line| 17 | str.puts "# #{line}" 18 | end 19 | end 20 | code = <<-CRYSTAL 21 | #{description.rstrip} 22 | struct #{crd.spec.names.kind} 23 | include Kubernetes::Serializable 24 | 25 | #{properties.to_crystal} 26 | 27 | #{spec.initializer} 28 | end 29 | 30 | Kubernetes.define_resource( 31 | name: #{crd.spec.names.plural.inspect}, 32 | group: #{crd.spec.group.inspect}, 33 | type: Kubernetes::Resource(#{crd.spec.names.kind}), 34 | kind: "#{crd.spec.names.kind}", 35 | version: #{version.name.inspect}, 36 | prefix: "apis", 37 | singular_name: #{crd.spec.names.singular.inspect}, 38 | ) 39 | CRYSTAL 40 | 41 | puts code 42 | end 43 | rescue ex 44 | STDERR.puts ex 45 | STDERR.puts ex.pretty_inspect 46 | exit 1 47 | end 48 | -------------------------------------------------------------------------------- /examples/florp_controller.cr: -------------------------------------------------------------------------------- 1 | require "../src/kubernetes" 2 | 3 | log = Log.for("florp.controller", level: :info) 4 | 5 | Kubernetes.import_crd "examples/crd-florps.yaml" 6 | 7 | k8s = Kubernetes::Client.new( 8 | server: URI.parse(ENV["K8S"]), 9 | token: ENV["TOKEN"], 10 | certificate_file: ENV["CA_CERT"], 11 | ) 12 | 13 | # Start watching for changes in our custom resources 14 | k8s.watch_florps do |watch| 15 | florp = watch.object 16 | 17 | case watch 18 | when .added?, .modified? 19 | log.info { "Florp #{florp.metadata.name} updated" } 20 | k8s.apply_deployment( 21 | api_version: "apps/v1", 22 | kind: "Deployment", 23 | metadata: { 24 | name: florp.metadata.name, 25 | namespace: florp.metadata.namespace, 26 | }, 27 | spec: florp_spec(florp), 28 | ) 29 | when .deleted? 30 | log.info { "Florp #{florp.metadata.name} deleted" } 31 | k8s.delete_deployment( 32 | name: florp.metadata.name, 33 | namespace: florp.metadata.namespace, 34 | ) 35 | end 36 | end 37 | 38 | def florp_spec(florp : Kubernetes::Resource(Florp)) 39 | labels = {app: florp.metadata.name} 40 | 41 | { 42 | replicas: florp.spec.count, 43 | selector: {matchLabels: labels}, 44 | template: { 45 | metadata: {labels: labels}, 46 | spec: { 47 | containers: [ 48 | { 49 | image: "busybox:latest", 50 | command: %w[sleep 10000], 51 | name: "sleep", 52 | env: env( 53 | NAME: florp.spec.name, 54 | ID: florp.spec.id, 55 | ), 56 | }, 57 | ], 58 | }, 59 | }, 60 | } 61 | end 62 | 63 | def env(**args) 64 | array = Array(NamedTuple(name: String, value: String)) 65 | .new(initial_capacity: args.size) 66 | 67 | args.each do |key, value| 68 | array << {name: key.to_s, value: value} 69 | end 70 | 71 | array 72 | end 73 | -------------------------------------------------------------------------------- /src/serializable.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "yaml" 3 | 4 | module Kubernetes 5 | module Serializable 6 | macro included 7 | include JSON::Serializable 8 | include YAML::Serializable 9 | end 10 | 11 | macro field(type, key = nil, **args, &block) 12 | add_field {{type}}, getter, key: {{key}} {% unless args.empty? %}{% for k, v in args %}, {{k}}: {{v}}{% end %}{% end %} {{block}} 13 | end 14 | 15 | macro field?(type, key = nil, **args, &block) 16 | add_field {{type}}, getter?, key: {{key}} {% unless args.empty? %}{% for k, v in args %}, {{k}}: {{v}}{% end %}{% end %} {{block}} 17 | end 18 | 19 | macro field!(type, key = nil, **args, &block) 20 | add_field {{type}}, getter!, key: {{key}} {% unless args.empty? %}{% for k, v in args %}, {{k}}: {{v}}{% end %}{% end %} {{block}} 21 | end 22 | 23 | macro add_field(type, getter_type, key = nil, **args, &block) 24 | @[JSON::Field(key: "{{(key || type.var.camelcase(lower: true)).id}}"{% unless args.empty? %}{% for k, v in args %}, {{k}}: {{v}}{% end %}{% end %})] 25 | @[YAML::Field(key: "{{(key || type.var.camelcase(lower: true)).id}}"{% unless args.empty? %}{% for k, v in args %}, {{k}}: {{v}}{% end %}{% end %})] 26 | {{getter_type}} {{type}} {{block}} 27 | {% if flag? :debug_k8s_add_field %} 28 | {% debug %} 29 | {% end %} 30 | end 31 | 32 | def pretty_print(pp) : Nil 33 | prefix = "#{{{@type.name.id.stringify}}}(" 34 | pp.surround(prefix, ")", left_break: "", right_break: nil) do 35 | count = 0 36 | {% for ivar, i in @type.instance_vars.map(&.name) %} 37 | if @{{ivar}} 38 | if (count += 1) > 1 39 | pp.comma 40 | end 41 | pp.group do 42 | pp.text "@{{ivar.id}}=" 43 | pp.nest do 44 | pp.breakable "" 45 | @{{ivar.id}}.pretty_print(pp) 46 | end 47 | end 48 | end 49 | {% end %} 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: webhook-test 6 | --- 7 | apiVersion: admissionregistration.k8s.io/v1 8 | kind: MutatingWebhookConfiguration 9 | metadata: 10 | name: pod-mutating-webhook.jgaskins.dev 11 | namespace: webhook-test 12 | annotations: 13 | cert-manager.io/inject-ca-from: webhook-test/pod-mutating-webhook 14 | webhooks: 15 | - name: pod-mutating-webhook.jgaskins.dev 16 | admissionReviewVersions: [v1] 17 | rules: 18 | - apiGroups: [""] 19 | apiVersions: [v1] 20 | operations: [CREATE, UPDATE] 21 | resources: [pods] 22 | # failurePolicy: Ignore 23 | clientConfig: 24 | service: 25 | namespace: webhook-test 26 | name: pod-mutating-webhook 27 | path: /pods 28 | port: 3000 29 | sideEffects: None 30 | --- 31 | apiVersion: v1 32 | kind: Service 33 | metadata: 34 | namespace: webhook-test 35 | name: pod-mutating-webhook 36 | spec: 37 | selector: 38 | app.kubernetes.io/name: pod-mutating-webhook 39 | ports: 40 | - port: 3000 41 | --- 42 | apiVersion: apps/v1 43 | kind: Deployment 44 | metadata: 45 | namespace: webhook-test 46 | name: pod-mutating-webhook 47 | spec: 48 | selector: 49 | matchLabels: &labels 50 | app.kubernetes.io/name: pod-mutating-webhook 51 | template: 52 | metadata: 53 | labels: *labels 54 | spec: 55 | nodeSelector: 56 | kubernetes.io/arch: arm64 57 | containers: 58 | - name: web 59 | image: jgaskins/kubernetes-examples:mutating-webhooks 60 | imagePullPolicy: Always 61 | env: 62 | - name: LOG_LEVEL 63 | value: DEBUG 64 | ports: 65 | - protocol: TCP 66 | name: http 67 | containerPort: 3000 68 | volumeMounts: 69 | - name: tls 70 | mountPath: /certs 71 | readOnly: true 72 | volumes: 73 | - name: tls 74 | secret: 75 | secretName: pod-mutating-webhook-tls 76 | 77 | ####### CERT STUFF ###### 78 | --- 79 | apiVersion: cert-manager.io/v1 80 | kind: Issuer 81 | metadata: 82 | name: selfsigned-issuer 83 | namespace: webhook-test 84 | spec: 85 | selfSigned: {} 86 | --- 87 | apiVersion: cert-manager.io/v1 88 | kind: Certificate 89 | metadata: 90 | name: pod-mutating-webhook 91 | namespace: webhook-test 92 | spec: 93 | secretName: pod-mutating-webhook-tls 94 | dnsNames: 95 | - pod-mutating-webhook.webhook-test.svc 96 | issuerRef: 97 | name: selfsigned-issuer 98 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/src/webhook.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "json" 3 | 4 | require "./admission_review" 5 | require "./json_patch" 6 | 7 | class WebhookHandler 8 | include HTTP::Handler 9 | 10 | @log : Log 11 | @handling_requests = Atomic(Int64).new(0) 12 | 13 | def initialize(@log) 14 | end 15 | 16 | # Modify this method to change the response we want to return 17 | def response_for(request) 18 | AdmissionReview::Response.new( 19 | # This must be the uid of the request according to the K8s docs. 20 | uid: request.uid, 21 | # This corresponds to JSONPatch::Operation in json_patch.cr 22 | patch_type: :json_patch, 23 | patch: [ 24 | # JSONPatch doesn't recursively add keys, so we need to make sure the 25 | # nodeSelector object exists before we try to add keys to it. 26 | JSONPatch.new( 27 | op: :add, 28 | path: "/spec/nodeSelector", 29 | value: {} of String => JSON::Any, 30 | ), 31 | # Now that we know the nodeSelector exists, we can add the key to it. 32 | JSONPatch.new( 33 | op: :add, 34 | # Tildes and slashes have surprising escape requirements in JSONPatch 35 | path: "/spec/nodeSelector/kubernetes.io~1arch", 36 | value: "arm64", 37 | ), 38 | ], 39 | # If you want to reject the pod (or changes to it), set this to false. 40 | allowed: true, 41 | ) 42 | end 43 | 44 | def call(context) 45 | @handling_requests.add 1 46 | response = context.response 47 | response.content_type = "application/json" 48 | 49 | if body = context.request.body 50 | begin 51 | review = AdmissionReview.from_json(body) 52 | rescue ex : JSON::ParseException 53 | # If we can't parse an AdmissionReview object, bail out 54 | context.response.status = :unprocessable_entity 55 | {error: "Cannot parse AdmissionReview"}.to_json context.response 56 | return 57 | end 58 | 59 | @log.debug { review.to_json } 60 | if request = review.request 61 | admission_response = response_for(request) 62 | response_review = AdmissionReview.new( 63 | api_version: review.api_version, 64 | kind: review.kind, 65 | response: admission_response, 66 | ) 67 | 68 | @log.debug { response_review.to_json } 69 | response_review.to_json response 70 | else 71 | response.status = :bad_request 72 | {error: "AdmissionReview must contain a request"}.to_json response 73 | end 74 | else 75 | response.status = :bad_request 76 | {error: "Must provide a request body"}.to_json response 77 | end 78 | ensure 79 | @handling_requests.sub 1 80 | end 81 | 82 | def handling_requests? 83 | @handling_requests.get > 0 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /examples/svcapp_controller.cr: -------------------------------------------------------------------------------- 1 | require "../src/kubernetes" 2 | 3 | Kubernetes.import_crd "examples/crd-serviceapps.yaml" 4 | log = Log.for("serviceapps.controller", level: :info) 5 | 6 | k8s = Kubernetes::Client.new( 7 | server: URI.parse(ENV["K8S"]), 8 | token: ENV["TOKEN"], 9 | certificate_file: ENV["CA_CERT"], 10 | ) 11 | 12 | k8s.watch_serviceapps do |watch| 13 | svcapp = watch.object 14 | 15 | case watch 16 | when .added?, .modified? 17 | log.info { "ServiceApp #{svcapp.metadata.namespace}/#{svcapp.metadata.name} updated" } 18 | labels = {app: svcapp.metadata.name} 19 | metadata = { 20 | name: svcapp.metadata.name, 21 | namespace: svcapp.metadata.namespace, 22 | } 23 | 24 | deployment = k8s.apply_deployment( 25 | api_version: "apps/v1", 26 | kind: "Deployment", 27 | metadata: metadata, 28 | spec: { 29 | replicas: svcapp.spec.replicas, 30 | selector: {matchLabels: labels}, 31 | template: { 32 | metadata: {labels: labels}, 33 | spec: { 34 | containers: [ 35 | { 36 | image: svcapp.spec.image, 37 | command: svcapp.spec.command, 38 | name: "web", 39 | # env: [ 40 | # # ... 41 | # ], 42 | }, 43 | ], 44 | }, 45 | }, 46 | }, 47 | ) 48 | log.info { "Deployment #{deployment} applied" } 49 | 50 | svc = k8s.apply_service( 51 | api_version: "v1", 52 | kind: "Service", 53 | metadata: metadata, 54 | spec: { 55 | type: "ClusterIP", 56 | selector: labels, 57 | ports: [{port: 3000}], 58 | sessionAffinity: "None", 59 | }, 60 | ) 61 | case svc 62 | in Kubernetes::Service 63 | log.info { "Service #{svc.metadata.namespace}/#{svc.metadata.name} applied" } 64 | in Kubernetes::Status 65 | log.info { "Service #{svcapp.metadata.namespace}/#{svcapp.metadata.name} could not be applied!" } 66 | end 67 | 68 | ingress = k8s.apply_ingress( 69 | api_version: "networking.k8s.io/v1", 70 | kind: "Ingress", 71 | metadata: metadata, 72 | spec: { 73 | rules: [ 74 | { 75 | host: svcapp.spec.domain, 76 | http: { 77 | paths: [ 78 | { 79 | backend: { 80 | service: { 81 | name: metadata[:name], 82 | port: {number: 3000}, 83 | }, 84 | }, 85 | path: "/", 86 | pathType: "Prefix", 87 | }, 88 | ], 89 | }, 90 | }, 91 | ], 92 | }, 93 | ) 94 | case ingress 95 | in Kubernetes::Resource(Kubernetes::Networking::Ingress) 96 | log.info { "Ingress #{ingress.metadata.namespace}/#{ingress.metadata.name} applied" } 97 | in Kubernetes::Status 98 | log.info { "Ingress #{svcapp.metadata.namespace}/#{svcapp.metadata.name} could not be applied: #{ingress.inspect}" } 99 | end 100 | when .deleted? 101 | name = svcapp.metadata.name 102 | namespace = svcapp.metadata.namespace 103 | 104 | k8s.delete_ingress(name: name, namespace: namespace) 105 | log.info { "Ingress #{namespace}/#{name} deleted" } 106 | k8s.delete_service(name: name, namespace: namespace) 107 | log.info { "Service #{namespace}/#{name} deleted" } 108 | k8s.delete_deployment(name: name, namespace: namespace) 109 | log.info { "Deployment #{namespace}/#{name} deleted" } 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/crd_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | require "../src/crd" 4 | 5 | crds_yaml = <<-YAML 6 | --- 7 | apiVersion: apiextensions.k8s.io/v1 8 | kind: CustomResourceDefinition 9 | metadata: 10 | name: rails-apps.jgaskins.dev 11 | spec: 12 | group: jgaskins.dev 13 | versions: 14 | - name: v1 15 | served: true 16 | storage: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | spec: 22 | type: object 23 | properties: 24 | image: { type: string } 25 | env: 26 | type: array 27 | items: 28 | type: object 29 | properties: { name: { type: string }, value: { type: string } } 30 | envFrom: 31 | type: array 32 | default: [] 33 | items: 34 | type: object 35 | properties: 36 | secretRef: 37 | type: object 38 | nullable: true 39 | properties: 40 | name: 41 | type: string 42 | required: 43 | - name 44 | web: 45 | type: object 46 | properties: 47 | command: 48 | type: array 49 | items: { type: string } 50 | worker: 51 | type: object 52 | properties: 53 | command: 54 | type: array 55 | items: 56 | type: string 57 | 58 | issuerRef: 59 | type: object 60 | properties: 61 | group: 62 | description: Group of the resource being referred to. 63 | type: string 64 | kind: 65 | description: Kind of the resource being referred to. 66 | type: string 67 | name: 68 | description: Name of the resource being referred to. 69 | type: string 70 | required: 71 | - name 72 | required: 73 | - image 74 | scope: Namespaced 75 | names: 76 | plural: rails-apps 77 | singular: rails-app 78 | kind: RailsApp 79 | shortNames: 80 | - rails 81 | - ra 82 | YAML 83 | resource_yaml = <<-YAML 84 | apiVersion: jgaskins.dev/v1 85 | kind: RailsApp 86 | metadata: 87 | name: forem 88 | namespace: example-forem 89 | spec: 90 | image: quay.io/forem/forem:latest 91 | env: 92 | - name: RAILS_ENV 93 | value: production 94 | web: 95 | command: ["bundle", "exec", "rails", "server"] 96 | worker: 97 | command: ["bundle", "exec", "sidekiq"] 98 | YAML 99 | 100 | describe Kubernetes::CRD do 101 | crd = Kubernetes::CRD.from_yaml crds_yaml 102 | 103 | it "idk lol" do 104 | spec = crd.spec 105 | v1 = spec.versions.first 106 | properties = v1.schema.open_api_v3_schema.properties.spec.properties 107 | 108 | properties["image"].type.should eq "string" 109 | properties.to_crystal.should contain "image : String" 110 | 111 | properties["env"].type.should eq "array" 112 | if items = properties["env"].items 113 | items.type.should eq "object" 114 | items.properties["name"].type.should eq "string" 115 | items.properties["value"].type.should eq "string" 116 | else 117 | raise "env.items should not be nil!" 118 | end 119 | properties.to_crystal.should contain "env : Array(Env)" 120 | 121 | properties["web"].type.should eq "object" 122 | properties["web"].properties["command"].type.should eq "array" 123 | properties["web"].properties["command"].items.not_nil!.type.should eq "string" 124 | properties["envFrom"].default.not_nil!.as_a.empty?.should eq true 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/fixtures/nats.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: natsclusters.jgaskins.dev 6 | spec: 7 | group: jgaskins.dev 8 | versions: 9 | - name: v1beta1 10 | served: true 11 | storage: true 12 | schema: 13 | openAPIV3Schema: 14 | type: object 15 | properties: 16 | spec: 17 | type: object 18 | properties: 19 | version: 20 | type: string 21 | default: "2.9.8" 22 | replicas: 23 | type: integer 24 | default: 3 25 | jetstreamSize: 26 | type: string 27 | default: "1Gi" 28 | imageName: 29 | type: string 30 | default: "nats" 31 | imageTag: 32 | type: string 33 | nullable: true 34 | leafnodes: 35 | type: object 36 | nullable: true 37 | properties: 38 | remotes: 39 | type: array 40 | default: [] 41 | items: 42 | type: object 43 | properties: 44 | url: 45 | type: string 46 | credentials: 47 | type: string 48 | required: [url, credentials] 49 | 50 | 51 | scope: Namespaced 52 | names: 53 | plural: natsclusters 54 | singular: natscluster 55 | kind: NATSCluster 56 | shortNames: 57 | - nats 58 | --- 59 | apiVersion: apiextensions.k8s.io/v1 60 | kind: CustomResourceDefinition 61 | metadata: 62 | name: natsstreams.jgaskins.dev 63 | spec: 64 | group: jgaskins.dev 65 | versions: 66 | - name: v1alpha1 67 | served: true 68 | storage: true 69 | schema: 70 | openAPIV3Schema: 71 | type: object 72 | required: 73 | - name 74 | properties: 75 | spec: 76 | type: object 77 | properties: 78 | name: 79 | type: string 80 | nullable: false 81 | description: 82 | type: string 83 | nullable: true 84 | storage: 85 | type: string 86 | nullable: false 87 | default: file 88 | enum: 89 | - file 90 | - memory 91 | scope: Namespaced 92 | names: 93 | plural: natsstreams 94 | singular: natsstream 95 | kind: NATSStream 96 | shortNames: 97 | - stream 98 | --- 99 | apiVersion: apiextensions.k8s.io/v1 100 | kind: CustomResourceDefinition 101 | metadata: 102 | name: natsconsumers.jgaskins.dev 103 | spec: 104 | group: jgaskins.dev 105 | versions: 106 | - name: v1alpha1 107 | served: true 108 | storage: true 109 | schema: 110 | openAPIV3Schema: 111 | type: object 112 | required: 113 | - stream_name 114 | properties: 115 | spec: 116 | type: object 117 | properties: 118 | stream_name: 119 | type: string 120 | nullable: false 121 | name: 122 | type: string 123 | nullable: false 124 | config: 125 | type: object 126 | properties: 127 | durable_name: 128 | type: string 129 | nullable: true 130 | ack_policy: 131 | type: string 132 | default: explicit 133 | nullable: false 134 | enum: 135 | - none 136 | - all 137 | - explicit 138 | ack_wait: 139 | type: integer 140 | description: The number of nanoseconds to wait for an acknowledgement after delivery 141 | nullable: true 142 | deliver_policy: 143 | type: string 144 | default: all 145 | nullable: false 146 | # enum: 147 | # - 148 | scope: Namespaced 149 | names: 150 | plural: natsconsumers 151 | singular: natsconsumer 152 | kind: NATSConsumer 153 | shortNames: 154 | - consumer 155 | -------------------------------------------------------------------------------- /examples/mutating_webhooks/src/admission_review.cr: -------------------------------------------------------------------------------- 1 | require "kubernetes" 2 | require "json" 3 | require "uuid/json" 4 | require "uuid/yaml" 5 | 6 | require "./base64_converter" 7 | require "./json_patch" 8 | 9 | struct AdmissionReview 10 | include Kubernetes::Serializable 11 | 12 | field api_version : String? 13 | field kind : String? 14 | field request : Request? 15 | field response : Response? 16 | 17 | def initialize( 18 | *, 19 | @api_version = "admission.k8s.io/v1", 20 | @kind = "AdmissionReview", 21 | @request = nil, 22 | @response = nil 23 | ) 24 | end 25 | 26 | struct Request 27 | include Kubernetes::Serializable 28 | include JSON::Serializable::Unmapped 29 | 30 | field uid : UUID? 31 | 32 | # # Fully-qualified group/version/kind of the incoming object 33 | # kind: 34 | # group: autoscaling 35 | # version: v1 36 | # kind: Scale 37 | field kind : Kind? 38 | 39 | struct Kind 40 | include Kubernetes::Serializable 41 | field group : String? 42 | field version : String? 43 | field kind : String? 44 | end 45 | 46 | # # Fully-qualified group/version/kind of the resource being modified 47 | # resource: 48 | # group: apps 49 | # version: v1 50 | # resource: deployments 51 | field resource : Resource? 52 | 53 | struct Resource 54 | include Kubernetes::Serializable 55 | 56 | field group : String? 57 | field version : String? 58 | field resource : String? 59 | end 60 | 61 | # # subresource, if the request is to a subresource 62 | # subResource: scale 63 | field sub_resource : String? 64 | 65 | # # Fully-qualified group/version/kind of the incoming object in the original request to the API server. 66 | # # This only differs from `kind` if the webhook specified `matchPolicy: Equivalent` and the 67 | # # original request to the API server was converted to a version the webhook registered for. 68 | # requestKind: 69 | # group: autoscaling 70 | # version: v1 71 | # kind: Scale 72 | field request_kind : Kind? 73 | 74 | # # Fully-qualified group/version/kind of the resource being modified in the original request to the API server. 75 | # # This only differs from `resource` if the webhook specified `matchPolicy: Equivalent` and the 76 | # # original request to the API server was converted to a version the webhook registered for. 77 | # requestResource: 78 | # group: apps 79 | # version: v1 80 | # resource: deployments 81 | field request_resource : Resource? 82 | 83 | # # subresource, if the request is to a subresource 84 | # # This only differs from `subResource` if the webhook specified `matchPolicy: Equivalent` and the 85 | # # original request to the API server was converted to a version the webhook registered for. 86 | # requestSubResource: scale 87 | field request_sub_resource : String? 88 | 89 | # # Name of the resource being modified 90 | # name: my-deployment 91 | field name : String? 92 | 93 | # # Namespace of the resource being modified, if the resource is namespaced (or is a Namespace object) 94 | # namespace: my-namespace 95 | field namespace : String? 96 | 97 | # # operation can be CREATE, UPDATE, DELETE, or CONNECT 98 | # operation: UPDATE 99 | field operation : Operation? 100 | enum Operation 101 | CREATE 102 | UPDATE 103 | DELETE 104 | CONNECT 105 | end 106 | 107 | # userInfo: 108 | field user_info : UserInfo? 109 | 110 | struct UserInfo 111 | include Kubernetes::Serializable 112 | # # Username of the authenticated user making the request to the API server 113 | # username: admin 114 | field username : String? 115 | # # UID of the authenticated user making the request to the API server 116 | # uid: 014fbff9a07c 117 | field uid : String? 118 | # # Group memberships of the authenticated user making the request to the API server 119 | # groups: 120 | # - system:authenticated 121 | # - my-admin-group 122 | field groups : Array(String) { [] of String } 123 | # # Arbitrary extra info associated with the user making the request to the API server. 124 | # # This is populated by the API server authentication layer and should be included 125 | # # if any SubjectAccessReview checks are performed by the webhook. 126 | # extra: 127 | # some-key: 128 | # - some-value1 129 | # - some-value2 130 | field extra : Hash(String, JSON::Any) { {} of String => JSON::Any } 131 | end 132 | 133 | # # object is the new object being admitted. 134 | # # It is null for DELETE operations. 135 | # object: 136 | # apiVersion: autoscaling/v1 137 | # kind: Scale 138 | field object : ObjectReference? 139 | 140 | struct ObjectReference 141 | include Kubernetes::Serializable 142 | field api_version : String? 143 | field kind : String? 144 | end 145 | 146 | # # oldObject is the existing object. 147 | # # It is null for CREATE and CONNECT operations. 148 | # oldObject: 149 | # apiVersion: autoscaling/v1 150 | # kind: Scale 151 | field old_object : ObjectReference? 152 | 153 | # # options contains the options for the operation being admitted, like meta.k8s.io/v1 CreateOptions, UpdateOptions, or DeleteOptions. 154 | # # It is null for CONNECT operations. 155 | # options: 156 | # apiVersion: meta.k8s.io/v1 157 | # kind: UpdateOptions 158 | field options : Options? 159 | 160 | struct Options 161 | include Kubernetes::Serializable 162 | 163 | field api_version : String? 164 | field kind : Kind? 165 | 166 | enum Kind 167 | CreateOptions 168 | UpdateOptions 169 | DeleteOptions 170 | end 171 | end 172 | 173 | # # dryRun indicates the API request is running in dry run mode and will not be persisted. 174 | # # Webhooks with side effects should avoid actuating those side effects when dryRun is true. 175 | # # See http://k8s.io/docs/reference/using-api/api-concepts/#make-a-dry-run-request for more details. 176 | # dryRun: False 177 | field? dry_run : Bool = false 178 | end 179 | 180 | struct Response 181 | include Kubernetes::Serializable 182 | field uid : UUID? 183 | field? allowed : Bool 184 | field patch_type : PatchType? = nil 185 | field patch : Array(JSONPatch), converter: Base64Converter(Array(JSONPatch)) do 186 | [] of JSONPatch 187 | end 188 | 189 | def initialize(*, @uid, @allowed, @patch_type = nil, @patch = nil) 190 | end 191 | 192 | enum PatchType 193 | JSONPatch 194 | 195 | def to_json(json) 196 | to_s.to_json json 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes 2 | 3 | A Kubernetes client that allows you to manage Kubernetes resources programmatically, similarly to how you might with `kubectl`. 4 | 5 | ## Installation 6 | 7 | Add this to your `shards.yml`: 8 | 9 | ```yaml 10 | dependencies: 11 | kubernetes: 12 | github: jgaskins/kubernetes 13 | ``` 14 | 15 | ## Usage 16 | 17 | First, instantiate your Kubernetes client: 18 | 19 | ```crystal 20 | require "kubernetes" 21 | 22 | k8s = Kubernetes::Client.new( 23 | server: URI.parse("https://#{ENV["KUBERNETES_SERVICE_HOST"]}:#{ENV["KUBERNETES_SERVICE_PORT"]}"), 24 | token: File.read("/var/run/secrets/kubernetes.io/serviceaccount/token"), 25 | certificate_file: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", 26 | ) 27 | ``` 28 | 29 | When you're running this inside a Kubernetes cluster, it will automatically find the Kubernetes API server, token, and cert file, so you can simplify even more: 30 | 31 | ```crystal 32 | require "kubernetes" 33 | 34 | k8s = Kubernetes::Client.new 35 | ``` 36 | 37 | Then you can fetch information about your deployments or pods: 38 | 39 | ```crystal 40 | pp k8s.deployments 41 | pp k8s.pods 42 | ``` 43 | 44 | ### `CustomResourceDefinition`s 45 | 46 | You can import a CRD directly from YAML. Let's say you have this CRD (taken from [the example in the Kubernetes CRD docs](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/)): 47 | 48 | ```yaml 49 | # k8s/crd-crontab.yaml 50 | --- 51 | apiVersion: apiextensions.k8s.io/v1 52 | kind: CustomResourceDefinition 53 | metadata: 54 | name: crontabs.stable.example.com 55 | spec: 56 | group: stable.example.com 57 | versions: 58 | - name: v1 59 | served: true 60 | storage: true 61 | schema: 62 | openAPIV3Schema: 63 | type: object 64 | properties: 65 | spec: 66 | type: object 67 | properties: 68 | cronSpec: 69 | type: string 70 | image: 71 | type: string 72 | replicas: 73 | type: integer 74 | scope: Namespaced 75 | names: 76 | plural: crontabs 77 | singular: crontab 78 | kind: CronTab 79 | shortNames: 80 | - ct 81 | ``` 82 | 83 | When you run `Kubernetes.import_crd("k8s/crd-crontab.yaml")`, the CRD will provide the following: 84 | 85 | 1. A `CronTab` struct 86 | - Represents the `spec` of the top-level `CronTab` resource in Kubernetes 87 | - The top-level resource is a `Kubernetes::Resource(CronTab)` 88 | - Has the following `getter` methods: 89 | - `cron_spec : String` 90 | - `image : String` 91 | - `replicas : Int64` 92 | 2. The following methods added to `Kubernetes::Client`: 93 | - `crontabs(namespace : String? = nil) : Array(Kubernetes::Resource(CronTab))` 94 | - When `namespace` is `nil`, _all_ `CronTab`s are returned 95 | - `crontab(name : String, namespace : String = "default") : Kubernetes::Resource(CronTab)` 96 | - `apply_crontab(api_version : String, kind : String, metadata, spec, force = false)` 97 | - Allows you to specify a complete Kubernetes manifest programmatically 98 | - On success, returns a `Kubernetes::Resource(CronTab)` representing the applied configuration 99 | - On failure, returns a `Kubernetes::Status` with information about the failure 100 | - `delete_crontab(name : String, namespace : String)` 101 | - `watch_crontabs(namespace : String, &on_change : Kubernetes::Watch(Kubernetes::Resource(CronTab)) ->)` 102 | - Allows you to define controllers that respond to changes in your custom resources 103 | 104 | ### Building a Controller 105 | 106 | Kubernetes controllers respond to changes in resources, often by making changes to other resources. For example, you might deploy a service that needs the following: 107 | 108 | - `Deployment` for a web app 109 | - `Service` to direct requests to that web app 110 | - `Ingress` to bring web requests to that service 111 | 112 | All of these things can be configured with a `ServiceApp` CRD. Let's say we have the following CRD: 113 | 114 | ```yaml 115 | # k8s/crd-serviceapp.yaml 116 | --- 117 | apiVersion: apiextensions.k8s.io/v1 118 | kind: CustomResourceDefinition 119 | metadata: 120 | name: serviceapps.example.com 121 | spec: 122 | group: example.com 123 | versions: 124 | - name: v1 125 | served: true 126 | storage: true 127 | schema: 128 | openAPIV3Schema: 129 | type: object 130 | properties: 131 | spec: 132 | type: object 133 | properties: 134 | domain: 135 | type: string 136 | command: 137 | type: array 138 | items: 139 | type: string 140 | minItems: 1 141 | image: 142 | type: string 143 | replicas: 144 | type: integer 145 | scope: Namespaced 146 | names: 147 | plural: serviceapps 148 | singular: serviceapp 149 | kind: ServiceApp 150 | shortNames: 151 | - svcapp 152 | ``` 153 | 154 | And whenever someone creates, updates, or deletes a `ServiceApp` resource, it needs to create, update, or delete the `Deployment`, `Service`, and `Ingress` resources accordingly: 155 | 156 | ```crystal 157 | require "kubernetes" 158 | 159 | Kubernetes.import_crd "k8s/crd-serviceapps.yaml" 160 | log = Log.for("serviceapps.controller", level: :info) 161 | 162 | k8s = Kubernetes::Client.new( 163 | server: URI.parse(ENV["K8S"]), 164 | token: ENV["TOKEN"], 165 | certificate_file: ENV["CA_CERT"], 166 | ) 167 | 168 | k8s.watch_serviceapps do |watch| 169 | svcapp = watch.object 170 | 171 | case watch 172 | when .added?, .modified? 173 | log.info { "ServiceApp #{svcapp.metadata.namespace}/#{svcapp.metadata.name} updated" } 174 | labels = {app: svcapp.metadata.name} 175 | metadata = { 176 | name: svcapp.metadata.name, 177 | namespace: svcapp.metadata.namespace, 178 | } 179 | 180 | deployment = k8s.apply_deployment( 181 | api_version: "apps/v1", 182 | kind: "Deployment", 183 | metadata: metadata, 184 | spec: { 185 | replicas: svcapp.spec.replicas, 186 | selector: {matchLabels: labels}, 187 | template: { 188 | metadata: {labels: labels}, 189 | spec: { 190 | containers: [ 191 | { 192 | image: svcapp.spec.image, 193 | command: svcapp.spec.command, 194 | name: "web", 195 | env: [ 196 | { name: "VAR_NAME", value: "var value" }, 197 | # ... 198 | ], 199 | }, 200 | ], 201 | }, 202 | }, 203 | }, 204 | ) 205 | log.info { "Deployment #{deployment} applied" } 206 | 207 | svc = k8s.apply_service( 208 | api_version: "v1", 209 | kind: "Service", 210 | metadata: metadata, 211 | spec: { 212 | type: "ClusterIP", 213 | selector: labels, 214 | ports: [{ port: 3000 }], 215 | sessionAffinity: "None", 216 | }, 217 | ) 218 | case svc 219 | in Kubernetes::Service 220 | log.info { "Service #{svc.metadata.namespace}/#{svc.metadata.name} applied" } 221 | in Kubernetes::Status 222 | log.info { "Service #{svcapp.metadata.namespace}/#{svcapp.metadata.name} could not be applied!" } 223 | end 224 | 225 | ingress = k8s.apply_ingress( 226 | api_version: "networking.k8s.io/v1", 227 | kind: "Ingress", 228 | metadata: metadata, 229 | spec: { 230 | rules: [ 231 | { 232 | host: svcapp.spec.domain, 233 | http: { 234 | paths: [ 235 | { 236 | backend: { 237 | service: { 238 | name: metadata[:name], 239 | port: { number: 3000 }, 240 | }, 241 | }, 242 | path: "/", 243 | pathType: "Prefix", 244 | }, 245 | ], 246 | }, 247 | }, 248 | ], 249 | }, 250 | ) 251 | case ingress 252 | in Kubernetes::Resource(Kubernetes::Networking::Ingress) 253 | log.info { "Ingress #{ingress.metadata.namespace}/#{ingress.metadata.name} applied" } 254 | in Kubernetes::Status 255 | log.info { "Ingress #{svcapp.metadata.namespace}/#{svcapp.metadata.name} could not be applied: #{ingress.inspect}" } 256 | end 257 | when .deleted? 258 | name = svcapp.metadata.name 259 | namespace = svcapp.metadata.namespace 260 | 261 | k8s.delete_ingress(name: name, namespace: namespace) 262 | log.info { "Ingress #{namespace}/#{name} deleted" } 263 | k8s.delete_service(name: name, namespace: namespace) 264 | log.info { "Service #{namespace}/#{name} deleted" } 265 | k8s.delete_deployment(name: name, namespace: namespace) 266 | log.info { "Deployment #{namespace}/#{name} deleted" } 267 | end 268 | end 269 | ``` 270 | 271 | ## Contributing 272 | 273 | 1. Fork it () 274 | 2. Create your feature branch (`git checkout -b my-new-feature`) 275 | 3. Commit your changes (`git commit -am 'Add some feature'`) 276 | 4. Push to the branch (`git push origin my-new-feature`) 277 | 5. Create a new Pull Request 278 | 279 | ## Contributors 280 | 281 | - [Jamie Gaskins](https://github.com/jgaskins) - creator and maintainer 282 | -------------------------------------------------------------------------------- /src/crd.cr: -------------------------------------------------------------------------------- 1 | require "./serializable" 2 | 3 | module Kubernetes 4 | struct CRD 5 | include Serializable 6 | 7 | field api_version : String 8 | field kind : String 9 | # field metadata : Metadata 10 | field spec : Spec 11 | 12 | struct Spec 13 | include Serializable 14 | 15 | field group : String 16 | field names : Names 17 | field scope : String 18 | field versions : Array(Version) 19 | 20 | struct Names 21 | include Serializable 22 | 23 | field plural : String 24 | field singular : String 25 | field kind : String 26 | field short_names : Array(String)? 27 | end 28 | 29 | struct Version 30 | include Serializable 31 | 32 | field name : String 33 | field served : Bool = false 34 | field storage : Bool = false 35 | field schema : Schema 36 | 37 | struct Schema 38 | include Serializable 39 | 40 | field open_api_v3_schema : OpenAPIV3Schema, key: "openAPIV3Schema" 41 | 42 | struct OpenAPIV3Schema 43 | include Serializable 44 | 45 | field type : String 46 | field description : String? 47 | field properties : Properties 48 | 49 | struct Properties 50 | include Serializable 51 | field spec : Spec 52 | 53 | class Spec 54 | include Serializable 55 | 56 | field type : String 57 | field description : String? 58 | field default : YAML::Any? 59 | field items : Spec? 60 | field properties : Properties { Properties.new } 61 | field? nullable : Bool = false 62 | field enum : Array(String)? 63 | field required : Array(String) { [] of String } 64 | field? preserve_unknown_fields : Bool = false, key: "x-kubernetes-preserve-unknown-fields" 65 | 66 | field additional_properties : Bool? | Spec?, key: "additionalProperties" 67 | 68 | def initializer 69 | String.build do |str| 70 | str.puts "def initialize(*," 71 | properties.each do |name, property| 72 | str << " @" << name.underscore 73 | if property.nullable? 74 | str << " = nil" 75 | elsif default = property.default 76 | if default_array = default.as_a? 77 | if (items = property.items) && default_array.empty? 78 | str << " = " << property.crystal_type(name) << ".new" 79 | else 80 | str << " = " << default.inspect 81 | end 82 | elsif default_hash = default.as_h? 83 | str << " = {} of String => JSON::Any" 84 | else 85 | str << " = " << default.inspect 86 | end 87 | end 88 | str.puts ',' 89 | end 90 | 91 | 92 | 93 | str.puts ')' 94 | str.puts "end" 95 | end 96 | end 97 | 98 | def to_crystal(name : String) 99 | String.build do |str| 100 | type_name = crystal_type(name) 101 | if nullable? 102 | type_name += "?" 103 | end 104 | 105 | str << type_name 106 | 107 | if default_value = default 108 | case type 109 | # Go doesn't emit empty arrays, so a default empty array 110 | # needs to be handled manually 111 | when "array" 112 | if default_array = default_value.as_a? 113 | if default_array.empty? 114 | if items = self.items 115 | str << " = [] of #{items.to_crystal(name)}" 116 | else 117 | raise "Array type specification for #{name.inspect} must contain an `items` key" 118 | end 119 | end 120 | else 121 | raise "Default value for an array must be an array. Got: #{default_value.inspect}" 122 | end 123 | when "string" 124 | if e = @enum 125 | str << " = :" << default_value 126 | else 127 | str << " = " 128 | default_value.inspect str 129 | end 130 | when "integer" 131 | str << " = " << default_value 132 | end 133 | end 134 | end 135 | end 136 | 137 | protected def crystal_type(name : String) 138 | case type 139 | when "integer" 140 | "Int64" 141 | when "string" 142 | if e = @enum 143 | name.camelcase 144 | else 145 | "String" 146 | end 147 | when "boolean" 148 | "Bool" 149 | when "array" 150 | if items = self.items 151 | "Array(#{items.to_crystal(name)})" 152 | else 153 | raise "Array type specification for #{name.inspect} must contain an `items` key" 154 | end 155 | when "object" 156 | if preserve_unknown_fields? || additional_properties == true 157 | "Hash(String, JSON::Any)" 158 | elsif apspec = additional_properties.as?(Spec) 159 | "Hash(String, #{apspec.to_crystal(name)})" 160 | else 161 | name.camelcase 162 | end 163 | else 164 | raise "Unknown type: #{type.inspect}" 165 | end 166 | end 167 | 168 | struct Properties 169 | include Enumerable({String, Spec}) 170 | 171 | alias Mapping = Hash(String, Spec) 172 | 173 | @mapping : Mapping = Mapping.new 174 | 175 | def initialize 176 | end 177 | 178 | def initialize(json : JSON::PullParser) 179 | @mapping = Mapping.new(json) 180 | end 181 | 182 | def initialize(ctx : YAML::ParseContext, value : YAML::Nodes::Node) 183 | @mapping = Mapping.new(ctx, value) 184 | end 185 | 186 | def each 187 | @mapping.each { |value| yield value } 188 | end 189 | 190 | def [](key : String) 191 | @mapping[key] 192 | end 193 | 194 | def to_json(json : JSON::Builder) 195 | @mapping.to_json json 196 | end 197 | 198 | def to_crystal 199 | String.build do |str| 200 | @mapping.each do |name, spec| 201 | spec.description.try &.each_line do |line| 202 | str.puts " # #{line}" 203 | end 204 | str << " @[YAML::Field(key: #{name.inspect})]\n" 205 | str << " @[JSON::Field(key: #{name.inspect})]\n" 206 | str << " getter #{name.underscore} : #{spec.to_crystal(name)}\n" 207 | if spec.type == "object" && !spec.preserve_unknown_fields? && !spec.additional_properties 208 | spec.description.try &.each_line do |line| 209 | str.puts " # #{line}" 210 | end 211 | str << <<-CRYSTAL 212 | struct #{name.camelcase} 213 | include ::Kubernetes::Serializable 214 | 215 | #{spec.properties.to_crystal} 216 | 217 | #{spec.initializer} 218 | end 219 | 220 | CRYSTAL 221 | elsif spec.type == "boolean" 222 | # Alias a predicate method ending in a question mark. 223 | str << <<-CRYSTAL 224 | def #{name.underscore}? 225 | #{name.underscore} 226 | end 227 | 228 | CRYSTAL 229 | elsif spec.type == "array" && (items = spec.items) && items.type == "object" 230 | spec.description.try &.each_line do |line| 231 | str.puts " # #{line}" 232 | end 233 | str << <<-CRYSTAL 234 | struct #{name.camelcase} 235 | include ::Kubernetes::Serializable 236 | 237 | #{items.properties.to_crystal} 238 | 239 | #{items.initializer} 240 | end 241 | 242 | CRYSTAL 243 | elsif spec.type == "string" && (e = spec.enum) 244 | spec.description.try &.each_line do |line| 245 | str.puts " # #{line}" 246 | end 247 | str.puts "enum #{name.camelcase}" 248 | e.each do |item| 249 | str.puts item.camelcase 250 | end 251 | str.puts "end" 252 | end 253 | end 254 | end 255 | end 256 | end 257 | end 258 | end 259 | end 260 | end 261 | end 262 | end 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /src/kubernetes.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "json" 3 | require "yaml" 4 | require "uuid" 5 | require "uuid/json" 6 | require "uuid/yaml" 7 | require "uri/yaml" 8 | require "db/pool" 9 | 10 | require "./serializable" 11 | 12 | module Kubernetes 13 | VERSION = "0.1.0" 14 | 15 | class Client 16 | def self.from_config(*, file : String = "#{ENV["HOME"]?}/.kube/config", context context_name : String? = nil) 17 | config = File.open(file) { |f| Config.from_yaml f } 18 | 19 | from_config( 20 | config: config, 21 | context: context_name || config.current_context, 22 | ) 23 | end 24 | 25 | def self.from_config(config : Config, *, context context_name : String = config.current_context) 26 | context_entry = config.contexts.find { |c| c.name == context_name } 27 | if !context_entry 28 | raise ArgumentError.new("No context #{context_name.inspect} found in Kubernetes config") 29 | end 30 | 31 | cluster_entry = config.clusters.find { |c| c.name == context_entry.context.cluster } 32 | if !cluster_entry 33 | raise ArgumentError.new("No cluster #{context_entry.context.cluster.inspect} found in Kubernetes config") 34 | end 35 | 36 | user_entry = config.users.find { |u| u.name == context_entry.context.user } 37 | if !user_entry 38 | raise ArgumentError.new("No user #{context_entry.context.user.inspect} found in Kubernetes config") 39 | end 40 | 41 | file = File.tempfile prefix: "kubernetes", suffix: ".crt" do |tempfile| 42 | Base64.decode cluster_entry.cluster.certificate_authority_data, tempfile 43 | end 44 | at_exit { file.delete } 45 | 46 | user = user_entry.user 47 | 48 | if (cert = user.client_certificate_data) && (key = user.client_key_data) 49 | client_cert_file = File.tempfile prefix: "kubernetes", suffix: ".crt" do |tempfile| 50 | Base64.decode cert, tempfile 51 | end 52 | private_key_file = File.tempfile prefix: "kubernetes", suffix: ".crt" do |tempfile| 53 | Base64.decode key, tempfile 54 | end 55 | at_exit { client_cert_file.delete; private_key_file.delete } 56 | 57 | new( 58 | token: -> { "" }, 59 | server: cluster_entry.cluster.server, 60 | certificate_file: file.path, 61 | client_cert_file: client_cert_file.path, 62 | private_key_file: private_key_file.path, 63 | ) 64 | elsif (token = user.token_data) 65 | new( 66 | server: cluster_entry.cluster.server, 67 | certificate_file: file.path, 68 | token: -> { token }, 69 | ) 70 | else 71 | new( 72 | server: cluster_entry.cluster.server, 73 | certificate_file: file.path, 74 | token: -> { user_entry.user.credential.try &.status.token || "" }, 75 | ) 76 | end 77 | end 78 | 79 | def self.new 80 | if host = ENV["KUBERNETES_SERVICE_HOST"]? 81 | if port = ENV["KUBERNETES_SERVICE_PORT"]? 82 | host += ":#{port}" 83 | end 84 | server = URI.parse("https://#{host}") 85 | new server: server 86 | elsif File.exists? "#{ENV["HOME"]?}/.kube/config" 87 | from_config 88 | else 89 | raise ArgumentError.new("Using `Kubernetes::Client.new` with no arguments can only be run where there is a valid `~/.kube/config` or from within a Kubernetes cluster with `KUBERNETES_SERVICE_HOST` set.") 90 | end 91 | end 92 | 93 | # Constructor that accepts a token file path and creates a proc to read it 94 | def self.new( 95 | server : URI, 96 | token_file : Path, 97 | certificate_file : String = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", 98 | client_cert_file : String? = nil, 99 | private_key_file : String? = nil, 100 | log = Log.for("kubernetes.client"), 101 | ) 102 | token_proc = -> { File.read(token_file.to_s).strip } 103 | 104 | new( 105 | server: server, 106 | token: token_proc, 107 | certificate_file: certificate_file, 108 | client_cert_file: client_cert_file, 109 | private_key_file: private_key_file, 110 | log: log, 111 | ) 112 | end 113 | 114 | def self.new( 115 | server : URI, 116 | token : Proc(String) = -> { File.read("/var/run/secrets/kubernetes.io/serviceaccount/token").strip }, 117 | certificate_file : String = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", 118 | client_cert_file : String? = nil, 119 | private_key_file : String? = nil, 120 | log = Log.for("kubernetes.client"), 121 | ) 122 | if certificate_file || client_cert_file || private_key_file 123 | tls = OpenSSL::SSL::Context::Client.new 124 | 125 | if certificate_file 126 | tls.ca_certificates = certificate_file 127 | end 128 | 129 | if private_key_file 130 | tls.private_key = private_key_file 131 | end 132 | 133 | if client_cert_file 134 | tls.certificate_chain = client_cert_file 135 | end 136 | end 137 | 138 | new( 139 | server: server, 140 | token: token, 141 | tls: tls, 142 | log: log, 143 | ) 144 | end 145 | 146 | def initialize( 147 | *, 148 | @server : URI = URI.parse("https://#{ENV["KUBERNETES_SERVICE_HOST"]}:#{ENV["KUBERNETES_SERVICE_PORT"]}"), 149 | @token : Proc(String) = -> { File.read("/var/run/secrets/kubernetes.io/serviceaccount/token").strip }, 150 | @tls : OpenSSL::SSL::Context::Client?, 151 | @log = Log.for("kubernetes.client"), 152 | ) 153 | @http_pool = DB::Pool(HTTP::Client).new do 154 | http = HTTP::Client.new(server, tls: @tls) 155 | http.before_request do |request| 156 | token_value = token.call 157 | 158 | if token_value.presence 159 | request.headers["Authorization"] = "Bearer #{token_value}" 160 | end 161 | end 162 | http 163 | end 164 | end 165 | 166 | def close : Nil 167 | @http_pool.close 168 | end 169 | 170 | def apis 171 | get("/apis") do |response| 172 | APIGroup::List.from_json response.body_io 173 | end 174 | end 175 | 176 | def api_resources(group : String) 177 | get("/apis/#{group}") do |response| 178 | APIResource::List.from_json response.body_io 179 | end 180 | end 181 | 182 | def get(path : String, headers = HTTP::Headers.new, *, as type : T.class) : T? forall T 183 | get path, headers do |response| 184 | case response.status 185 | when .ok? 186 | T.from_json(response.body_io) 187 | when .not_found? 188 | nil 189 | else 190 | raise UnexpectedResponse.new("Unexpected response status: #{response.status_code} - #{response.body_io.gets_to_end}") 191 | end 192 | end 193 | end 194 | 195 | def get(path : String, headers = HTTP::Headers.new, &) 196 | @http_pool.checkout do |http| 197 | path = path.gsub(%r{//+}, '/') 198 | http.get path, headers: headers do |response| 199 | yield response 200 | ensure 201 | response.body_io.skip_to_end 202 | end 203 | end 204 | end 205 | 206 | def put(path : String, body, headers = HTTP::Headers.new) 207 | @http_pool.checkout do |http| 208 | path = path.gsub(%r{//+}, '/') 209 | http.put path, headers: headers, body: body.to_json 210 | end 211 | end 212 | 213 | def raw_patch(path : String, body, headers = HTTP::Headers.new) 214 | @http_pool.checkout do |http| 215 | path = path.gsub(%r{//+}, '/') 216 | http.patch path, headers: headers, body: body 217 | end 218 | end 219 | 220 | def patch(path : String, body, headers = HTTP::Headers.new) 221 | headers["Content-Type"] = "application/apply-patch+yaml" 222 | 223 | @http_pool.checkout do |http| 224 | path = path.gsub(%r{//+}, '/') 225 | http.patch path, headers: headers, body: body.to_yaml 226 | end 227 | end 228 | 229 | def delete(path : String, headers = HTTP::Headers.new) 230 | @http_pool.checkout do |http| 231 | path = path.gsub(%r{//+}, '/') 232 | http.delete path, headers: headers 233 | end 234 | end 235 | 236 | private def make_label_selector_string(label_selector : String | Nil) 237 | label_selector 238 | end 239 | 240 | private def make_label_selector_string(kwargs : NamedTuple) 241 | make_label_selector_string(**kwargs) 242 | end 243 | 244 | private def make_label_selector_string(**kwargs : String) 245 | size = 0 246 | kwargs.each do |key, value| 247 | size += key.to_s.bytesize + value.bytesize + 2 # ',' and '=' 248 | end 249 | 250 | String.build size do |str| 251 | kwargs.each_with_index 1 do |key, value, index| 252 | key.to_s str 253 | str << '=' << value 254 | unless index == kwargs.size 255 | str << ',' 256 | end 257 | end 258 | end 259 | end 260 | 261 | private def make_label_selector_string(labels : Hash(String, String)) 262 | size = 0 263 | labels.each do |(key, value)| 264 | size += key.bytesize + value.bytesize + 2 # ',' and '=' 265 | end 266 | 267 | String.build size do |str| 268 | labels.each_with_index 1 do |(key, value), index| 269 | str << key << '=' << value 270 | 271 | unless index == labels.size 272 | str << ',' 273 | end 274 | end 275 | end 276 | end 277 | end 278 | 279 | struct Resource(T) 280 | include Serializable 281 | 282 | field api_version : String { "" } 283 | field kind : String { "" } 284 | field metadata : Metadata 285 | field spec : T 286 | field status : JSON::Any = JSON::Any.new(nil) 287 | 288 | def initialize(*, @api_version, @kind, @metadata, @spec, @status = JSON::Any.new(nil)) 289 | end 290 | end 291 | 292 | struct Metadata 293 | include Serializable 294 | 295 | DEFAULT_TIME = Time.new(seconds: 0, nanoseconds: 0, location: Time::Location::UTC) 296 | 297 | field name : String = "" 298 | field namespace : String { "" } 299 | field labels : Hash(String, String) { {} of String => String } 300 | field annotations : Hash(String, String) { {} of String => String } 301 | field resource_version : String, ignore_serialize: true { "" } 302 | field generate_name : String, ignore_serialize: true { "" } 303 | field generation : Int64, ignore_serialize: true { 0i64 } 304 | field creation_timestamp : Time = DEFAULT_TIME, ignore_serialize: true 305 | field deletion_timestamp : Time?, ignore_serialize: true 306 | field owner_references : Array(OwnerReferenceApplyConfiguration), ignore_serialize: true do 307 | [] of OwnerReferenceApplyConfiguration 308 | end 309 | field finalizers : Array(String) { %w[] } 310 | field uid : UUID, ignore_serialize: true { UUID.empty } 311 | 312 | def initialize(@name, @namespace = nil, @labels = {} of String => String, @annotations = {} of String => String) 313 | end 314 | end 315 | 316 | struct OwnerReferenceApplyConfiguration 317 | include Serializable 318 | 319 | field api_version : String? 320 | field kind : String? 321 | field name : String? 322 | field uid : UUID? 323 | field controller : Bool = false 324 | field block_owner_deletion : Bool? 325 | end 326 | 327 | enum PropagationPolicy 328 | Background 329 | Foreground 330 | Orphan 331 | end 332 | 333 | struct Service 334 | include Serializable 335 | 336 | field api_version : String = "v1" 337 | field kind : String = "Service" 338 | field metadata : Metadata 339 | field spec : Spec 340 | field status : Status 341 | 342 | struct Spec 343 | include Serializable 344 | 345 | field cluster_ip : String = "", key: "clusterIP" 346 | field cluster_ips : Array(String), key: "clusterIPs" { %w[] } 347 | field external_ips : Array(String), key: "externalIPs" { %w[] } 348 | field external_name : String? 349 | field external_traffic_policy : TrafficPolicy? 350 | field health_check_node_port : Int32? 351 | field internal_traffic_policy : TrafficPolicy? 352 | field ip_families : Array(IPFamily) { [] of IPFamily } 353 | field ip_family_policy : IPFamilyPolicy 354 | field load_balancer_ip : String?, key: "loadBalancerIP" 355 | field load_balancer_source_ranges : String? 356 | field ports : Array(Port) = [] of Port 357 | field publish_not_ready_addresses : Bool? 358 | field selector : Hash(String, String)? 359 | field session_affinity : SessionAffinity? 360 | field session_affinity_config : SessionAffinityConfig? 361 | field type : Type 362 | 363 | def initialize(@ports : Array(Port), @type = :cluster_ip, @ip_family_policy = :single_stack) 364 | end 365 | end 366 | 367 | enum Type 368 | ClusterIP 369 | ExternalName 370 | NodePort 371 | LoadBalancer 372 | end 373 | 374 | enum TrafficPolicy 375 | Local 376 | Cluster 377 | end 378 | 379 | enum IPFamily 380 | IPv4 381 | IPv6 382 | end 383 | 384 | enum IPFamilyPolicy 385 | SingleStack 386 | PreferDualStack 387 | RequireDualStack 388 | end 389 | 390 | struct Status 391 | include Serializable 392 | 393 | field conditions : Array(Condition) { [] of Condition } 394 | field load_balancer : LoadBalancer::Status? 395 | end 396 | 397 | module LoadBalancer 398 | struct Status 399 | include Serializable 400 | 401 | field ingress : Array(Ingress) { [] of Ingress } 402 | end 403 | 404 | struct Ingress 405 | include Serializable 406 | 407 | field hostname : String = "" 408 | field ip : String = "" 409 | field ports : Array(PortStatus) { [] of PortStatus } 410 | end 411 | 412 | struct PortStatus 413 | include Serializable 414 | 415 | field error : String? 416 | field port : Int32 = -1 417 | field protocol : Port::Protocol 418 | end 419 | end 420 | 421 | struct Condition 422 | include Serializable 423 | 424 | field last_transition_time : Time 425 | field message : String = "" 426 | field observed_generation : Int64 427 | field reason : String = "" 428 | field status : Status 429 | field type : String 430 | 431 | enum Status 432 | True 433 | False 434 | Unknown 435 | end 436 | end 437 | 438 | struct Port 439 | include Serializable 440 | 441 | field app_protocol : String? 442 | field name : String = "" 443 | field node_port : Int32? 444 | field port : Int32 445 | field protocol : Protocol 446 | field target_port : Int32 | String | Nil 447 | 448 | enum Protocol 449 | TCP 450 | UDP 451 | STCP 452 | end 453 | end 454 | 455 | enum SessionAffinity 456 | None 457 | ClientIP 458 | end 459 | 460 | struct SessionAffinityConfig 461 | include Serializable 462 | 463 | field client_ip : ClientIPConfig? 464 | end 465 | 466 | struct ClientIPConfig 467 | include Serializable 468 | 469 | field timeout_seconds : Int32 470 | end 471 | end 472 | 473 | struct APIGroup 474 | include Serializable 475 | 476 | field name : String 477 | field versions : Array(Version) 478 | field preferred_version : Version 479 | 480 | struct List 481 | include Serializable 482 | 483 | field api_version : String 484 | field kind : String 485 | field groups : Array(APIGroup) 486 | end 487 | 488 | struct Version 489 | include Serializable 490 | 491 | field group_version : String 492 | field version : String 493 | end 494 | end 495 | 496 | struct APIResource 497 | include Serializable 498 | 499 | field name : String 500 | field singular_name : String 501 | field namespaced : Bool 502 | field kind : String 503 | field verbs : Array(String) 504 | field short_names : Array(String) { %w[] } 505 | field storage_version_hash : String? 506 | 507 | def initialize( 508 | *, 509 | @name, 510 | @singular_name, 511 | @namespaced, 512 | @kind, 513 | @verbs, 514 | @short_names = nil, 515 | @storage_version_hash = nil, 516 | ) 517 | end 518 | 519 | struct List 520 | include Serializable 521 | 522 | field api_version : String? 523 | field kind : String = "APIResourceList" 524 | field group_version : String 525 | field resources : Array(APIResource) 526 | 527 | def initialize(*, @api_version, @group_version, @resources) 528 | end 529 | end 530 | end 531 | 532 | struct Event 533 | include Serializable 534 | 535 | field metadata : Metadata 536 | field event_time : String? 537 | field reason : String 538 | field regarding : Regarding 539 | field note : String 540 | field type : String 541 | 542 | struct Regarding 543 | include Serializable 544 | 545 | field kind : String 546 | field namespace : String 547 | field name : String 548 | field uid : UUID 549 | field api_version : String 550 | field resource_version : String 551 | field field_path : String? 552 | end 553 | 554 | struct Metadata 555 | include Serializable 556 | 557 | field name : String 558 | field namespace : String 559 | field uid : UUID 560 | field resource_version : String 561 | field creation_timestamp : Time 562 | end 563 | end 564 | 565 | struct StatefulSet 566 | include Serializable 567 | 568 | field replicas : Int32 569 | field template : JSON::Any 570 | field selector : JSON::Any 571 | field volume_claim_templates : JSON::Any 572 | end 573 | 574 | struct Deployment 575 | include Serializable 576 | 577 | field metadata : Metadata 578 | field spec : Spec 579 | field status : Status? 580 | 581 | def initialize(*, @metadata, @spec, @status = nil) 582 | end 583 | 584 | struct Status 585 | include Serializable 586 | 587 | field observed_generation : Int32 = -1 588 | field replicas : Int32 = -1 589 | field updated_replicas : Int32 = -1 590 | field ready_replicas : Int32 = -1 591 | field available_replicas : Int32 = -1 592 | field conditions : Array(Condition) = [] of Condition 593 | 594 | struct Condition 595 | include Serializable 596 | 597 | field type : String 598 | field status : String 599 | field last_update_time : Time 600 | field last_transition_time : Time 601 | field reason : String 602 | field message : String 603 | end 604 | end 605 | 606 | struct Spec 607 | include Serializable 608 | 609 | field replicas : Int32 610 | field selector : Selector 611 | field template : PodTemplate 612 | field strategy : Strategy 613 | field revision_history_limit : Int32 614 | field progress_deadline_seconds : Int32 615 | 616 | def initialize( 617 | *, 618 | @replicas = 1, 619 | @selector = Selector.new, 620 | @template, 621 | @strategy = Strategy.new, 622 | @revision_history_limit = 10, 623 | @progress_deadline_seconds = 600, 624 | ) 625 | end 626 | 627 | struct Strategy 628 | include Serializable 629 | 630 | field type : String 631 | field rolling_update : RollingUpdate? 632 | 633 | struct RollingUpdate 634 | include Serializable 635 | 636 | field max_unavailable : String | Int32 637 | field max_surge : String | Int32 638 | end 639 | end 640 | 641 | struct Selector 642 | include Serializable 643 | 644 | field match_labels : Hash(String, String) = {} of String => String 645 | end 646 | end 647 | 648 | struct Metadata 649 | include Serializable 650 | 651 | field name : String = "" 652 | field namespace : String = "" 653 | field uid : UUID = UUID.empty 654 | field resource_version : String = "" 655 | field generation : Int64 = -1 656 | field creation_timestamp : Time = Time::UNIX_EPOCH 657 | field labels : Hash(String, String) = {} of String => String 658 | field annotations : Hash(String, String) = {} of String => String 659 | end 660 | end 661 | 662 | struct PodTemplate 663 | include Serializable 664 | 665 | field metadata : Metadata? 666 | field spec : PodSpec? 667 | end 668 | 669 | # https://github.com/kubernetes/kubernetes/blob/2dede1d4d453413da6fd852e00fc7d4c8784d2a8/staging/src/k8s.io/client-go/applyconfigurations/core/v1/podspec.go#L27-L63 670 | struct PodSpec 671 | include Serializable 672 | 673 | field containers : Array(Container) 674 | field restart_policy : String? 675 | field termination_grace_period_seconds : Int32 676 | field dns_policy : String 677 | field service_account_name : String? 678 | field service_account : String? 679 | field security_context : JSON::Any 680 | field scheduler_name : String 681 | end 682 | 683 | struct Container 684 | include Serializable 685 | 686 | field name : String 687 | field image : String 688 | field args : Array(String) = %w[] 689 | field ports : Array(Port) = [] of Port 690 | field env : Array(EnvVar) = [] of EnvVar 691 | field resources : Resources = Resources.new 692 | field termination_message_path : String? 693 | field termination_message_policy : String? 694 | field image_pull_policy : String 695 | 696 | struct Resources 697 | include Serializable 698 | 699 | field requests : Resource? 700 | field limits : Resource? 701 | 702 | def initialize 703 | end 704 | 705 | struct Resource 706 | include Serializable 707 | 708 | field cpu : String? 709 | field memory : String? 710 | end 711 | end 712 | 713 | struct EnvVar 714 | include Serializable 715 | 716 | field name : String 717 | field value : String { "" } 718 | field value_from : EnvVarSource? 719 | end 720 | 721 | struct EnvVarSource 722 | include Serializable 723 | 724 | field field_ref : ObjectFieldSelector? 725 | field config_map_key_ref : ConfigMapKeySelector? 726 | field resource_field_ref : ResourceFieldSelector? 727 | field secret_key_ref : SecretKeySelector? 728 | end 729 | 730 | struct ObjectFieldSelector 731 | include Serializable 732 | 733 | field api_version : String = "v1" 734 | field field_path : String 735 | end 736 | 737 | struct ConfigMapKeySelector 738 | include Serializable 739 | 740 | field key : String 741 | field name : String 742 | field optional : Bool? 743 | end 744 | 745 | struct ResourceFieldSelector 746 | include Serializable 747 | 748 | field container_name : String? 749 | field divisor : JSON::Any 750 | field resource : String 751 | end 752 | 753 | struct SecretKeySelector 754 | include Serializable 755 | 756 | field key : String 757 | field name : String 758 | field optional : Bool? 759 | end 760 | 761 | struct Port 762 | include Serializable 763 | 764 | field container_port : Int32? 765 | field protocol : String? 766 | end 767 | end 768 | 769 | struct CronJob 770 | include Serializable 771 | 772 | field schedule : String 773 | field job_template : JobTemplate 774 | 775 | struct JobTemplate 776 | include Serializable 777 | 778 | field spec : Spec 779 | 780 | struct Spec 781 | include Serializable 782 | 783 | field template : Job 784 | end 785 | end 786 | end 787 | 788 | # https://github.com/kubernetes/kubernetes/blob/2dede1d4d453413da6fd852e00fc7d4c8784d2a8/staging/src/k8s.io/client-go/applyconfigurations/batch/v1/jobspec.go#L29-L40 789 | struct Job 790 | include Serializable 791 | 792 | field parallelism : Int32? 793 | field completions : Int32? 794 | field active_deadline_seconds : Int64? 795 | field backoff_limit : Int32? 796 | # TODO: Should Selector be extracted to a higher layer? 797 | field selector : Deployment::Spec::Selector? 798 | field? manual_selector : Bool? 799 | # TODO: ditto? 800 | field template : PodTemplate? 801 | field ttl_seconds_after_finished : Int32? 802 | field completion_mode : String? 803 | field? suspend : Bool? 804 | end 805 | 806 | struct Pod 807 | include Serializable 808 | 809 | field metadata : Metadata 810 | field spec : Spec 811 | field status : JSON::Any 812 | 813 | struct Spec 814 | include Serializable 815 | 816 | field volumes : Array(Volume) { [] of Volume } 817 | field containers : Array(Container) 818 | field restart_policy : String? 819 | field termination_grace_period_seconds : Int32 820 | field dns_policy : String 821 | field service_account_name : String? 822 | field service_account : String? 823 | field node_name : String = "" 824 | field security_context : JSON::Any 825 | field scheduler_name : String 826 | field tolerations : Array(Toleration) 827 | 828 | struct Toleration 829 | include Serializable 830 | 831 | field key : String = "" 832 | field operator : String = "" 833 | field effect : String = "" 834 | field toleration_seconds : Int32 = 0 835 | end 836 | 837 | struct Volume 838 | include Serializable 839 | 840 | field name : String 841 | field projected : Template? 842 | 843 | struct Template 844 | include Serializable 845 | 846 | field sources : Array(JSON::Any) 847 | field default_mode : Int32 848 | end 849 | end 850 | end 851 | 852 | struct Metadata 853 | include Serializable 854 | 855 | field namespace : String 856 | field name : String 857 | field generate_name : String? 858 | field uid : UUID 859 | field resource_version : String 860 | field creation_timestamp : Time 861 | field labels : Hash(String, String) = {} of String => String 862 | field annotations : Hash(String, String) = {} of String => String 863 | field owner_references : Array(OwnerReference) = [] of OwnerReference 864 | 865 | struct OwnerReference 866 | include Serializable 867 | field api_version : String = "apps/v1" 868 | field name : String 869 | field kind : String 870 | field uid : UUID 871 | field controller : Bool 872 | field block_owner_deletion : Bool? 873 | end 874 | end 875 | end 876 | 877 | module Networking 878 | struct Ingress 879 | include Serializable 880 | 881 | field ingress_class_name : String? 882 | field rules : Array(Rule) { [] of Rule } 883 | field tls : Array(TLS) { [] of TLS } 884 | 885 | struct Rule 886 | include Serializable 887 | 888 | field host : String 889 | field http : HTTP 890 | 891 | struct HTTP 892 | include Serializable 893 | 894 | field paths : Array(Path) 895 | 896 | struct Path 897 | include Serializable 898 | 899 | field path : String 900 | field path_type : PathType 901 | field backend : Backend 902 | 903 | enum PathType 904 | ImplementationSpecific 905 | Exact 906 | Prefix 907 | 908 | def to_json(json : ::JSON::Builder) : Nil 909 | to_s.to_json(json) 910 | end 911 | 912 | def to_yaml(yaml : YAML::Nodes::Builder) : Nil 913 | to_s.to_yaml(yaml) 914 | end 915 | end 916 | end 917 | end 918 | end 919 | 920 | struct TLS 921 | include Serializable 922 | field hosts : Array(String) 923 | field secret_name : String 924 | end 925 | 926 | struct Backend 927 | include Serializable 928 | 929 | getter service : Service 930 | 931 | struct Service 932 | include Serializable 933 | 934 | field name : String 935 | field port : Port 936 | 937 | struct Port 938 | include Serializable 939 | 940 | field number : Int32 941 | end 942 | end 943 | end 944 | end 945 | end 946 | 947 | struct List(T) 948 | include Serializable 949 | include Enumerable(T) 950 | 951 | field api_version : String 952 | field kind : String 953 | field metadata : Metadata 954 | field items : Array(T) 955 | 956 | delegate each, to: items 957 | end 958 | 959 | struct Watch(T) 960 | include Serializable 961 | 962 | field type : Type 963 | field object : T 964 | 965 | delegate added?, modified?, deleted?, error?, to: type 966 | 967 | def initialize(@type : Type, @object : T) 968 | end 969 | 970 | enum Type 971 | ADDED 972 | MODIFIED 973 | DELETED 974 | ERROR 975 | end 976 | end 977 | 978 | struct Status 979 | include Serializable 980 | 981 | field kind : String 982 | field api_version : String 983 | field metadata : Metadata 984 | field status : String 985 | field message : String 986 | field reason : String = "" 987 | field details : Details = Details.new 988 | field code : Int32 989 | 990 | struct Details 991 | include Serializable 992 | 993 | field name : String = "" 994 | field group : String = "" 995 | field kind : String = "" 996 | 997 | def initialize 998 | end 999 | end 1000 | end 1001 | 1002 | # Define a new Kubernetes resource type. This can be used to specify your CRDs 1003 | # to be able to manage your custom resources in Crystal code. 1004 | macro define_resource(name, group, type, version = "v1", prefix = "apis", api_version = nil, kind = nil, list_type = nil, singular_name = nil, cluster_wide = false) 1005 | {% api_version ||= "#{group}/#{version}" %} 1006 | {% if kind == nil %} 1007 | {% if type.resolve == ::Kubernetes::Resource %} 1008 | {% kind = type.type_vars.first %} 1009 | {% else %} 1010 | {% kind = type.stringify %} 1011 | {% end %} 1012 | {% end %} 1013 | {% singular_name ||= name.gsub(/s$/, "").id %} 1014 | {% plural_method_name = name.gsub(/-/, "_") %} 1015 | {% singular_method_name = singular_name.gsub(/-/, "_") %} 1016 | 1017 | class ::Kubernetes::Client 1018 | def {{plural_method_name.id}}( 1019 | {% if cluster_wide == false %} 1020 | namespace : String? = "default", 1021 | {% end %} 1022 | # FIXME: Currently this is intended to be a string, but maybe we should 1023 | # make it a Hash/NamedTuple? 1024 | label_selector = nil, 1025 | ) 1026 | label_selector = make_label_selector_string(label_selector) 1027 | {% if cluster_wide == false %} 1028 | namespace &&= "/namespaces/#{namespace}" 1029 | {% else %} 1030 | namespace = nil 1031 | {% end %} 1032 | params = URI::Params.new 1033 | params["labelSelector"] = label_selector if label_selector 1034 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}#{namespace}/{{name.id}}?#{params}" 1035 | get path do |response| 1036 | case response.status 1037 | when .ok? 1038 | # JSON.parse response.body_io 1039 | {% if list_type %} 1040 | {{list_type}}.from_json response.body_io 1041 | {% else %} 1042 | ::Kubernetes::List({{type}}).from_json response.body_io 1043 | {% end %} 1044 | when .not_found? 1045 | raise ClientError.new "API resource \"{{name.id}}\" not found. Did you apply the CRD to the Kubernetes control plane?" 1046 | else 1047 | raise Error.new("Unknown Kubernetes API response: #{response.status} - please report to https://github.com/jgaskins/kubernetes/issues") 1048 | end 1049 | end 1050 | end 1051 | 1052 | def {{singular_method_name.id}}(name : String, namespace : String = "default", resource_version : String = "") 1053 | namespace = "/namespaces/#{namespace}" 1054 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}#{namespace}/{{name.id}}/#{name}" 1055 | params = URI::Params{ 1056 | "resourceVersion" => resource_version, 1057 | } 1058 | 1059 | get "#{path}?#{params}" do |response| 1060 | case value = ({{type}} | Status).from_json response.body_io 1061 | when Status 1062 | nil 1063 | else 1064 | value 1065 | end 1066 | end 1067 | end 1068 | 1069 | def apply_{{singular_method_name.id}}( 1070 | resource : {{type}}, 1071 | spec, 1072 | name : String = resource.metadata.name, 1073 | {% unless cluster_wide %} 1074 | namespace : String? = resource.metadata.namespace, 1075 | {% end %} 1076 | force : Bool = false, 1077 | field_manager : String? = nil, 1078 | ) 1079 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}{{cluster_wide ? "".id : "/namespaces/\#{namespace}".id}}/{{name.id}}/#{name}" 1080 | params = URI::Params{ 1081 | "force" => force.to_s, 1082 | "fieldManager" => field_manager || "k8s-cr", 1083 | } 1084 | metadata = { 1085 | name: name, 1086 | namespace: namespace, 1087 | } 1088 | if resource_version = resource.metadata.resource_version.presence 1089 | metadata = metadata.merge(resourceVersion: resource_version) 1090 | end 1091 | 1092 | response = patch "#{path}?#{params}", { 1093 | apiVersion: resource.api_version, 1094 | kind: resource.kind, 1095 | metadata: metadata, 1096 | spec: spec, 1097 | } 1098 | 1099 | if body = response.body 1100 | {{type}}.from_json response.body 1101 | else 1102 | raise "Missing response body" 1103 | end 1104 | end 1105 | 1106 | def apply_{{singular_method_name.id}}( 1107 | metadata : NamedTuple | Metadata, 1108 | api_version : String = "{{group.id}}/{{version.id}}", 1109 | kind : String = "{{kind.id}}", 1110 | force : Bool = false, 1111 | field_manager : String? = nil, 1112 | **kwargs, 1113 | ) 1114 | case metadata 1115 | in NamedTuple 1116 | name = metadata[:name] 1117 | {% if cluster_wide == false %} 1118 | namespace = metadata[:namespace] 1119 | {% end %} 1120 | in Metadata 1121 | name = metadata.name 1122 | {% if cluster_wide == false %} 1123 | namespace = metadata.namespace 1124 | {% end %} 1125 | end 1126 | 1127 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}{% if cluster_wide == false %}/namespaces/#{namespace}{% end %}/{{name.id}}/#{name}" 1128 | params = URI::Params{ 1129 | "force" => force.to_s, 1130 | "fieldManager" => field_manager || "k8s-cr", 1131 | } 1132 | response = patch "#{path}?#{params}", { 1133 | apiVersion: api_version, 1134 | kind: kind, 1135 | metadata: metadata, 1136 | }.merge(kwargs) 1137 | 1138 | if body = response.body 1139 | # {{type}}.from_json response.body 1140 | # JSON.parse body 1141 | ({{type}} | Status).from_json body 1142 | else 1143 | raise "Missing response body" 1144 | end 1145 | end 1146 | 1147 | def patch_{{singular_method_name.id}}(name : String, {% if cluster_wide == false %}namespace, {% end %}**kwargs) 1148 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}{% if cluster_wide == false %}/namespaces/#{namespace}{% end %}/{{name.id}}/#{name}" 1149 | headers = HTTP::Headers{ 1150 | "Content-Type" => "application/merge-patch+json", 1151 | } 1152 | 1153 | response = raw_patch path, kwargs.to_json, headers: headers 1154 | if body = response.body 1155 | ({{type}} | Status).from_json body 1156 | else 1157 | raise "Missing response body" 1158 | end 1159 | end 1160 | 1161 | def patch_{{singular_method_name.id}}_subresource(name : String, subresource : String{% if cluster_wide == false %}, namespace : String = "default"{% end %}, **args) 1162 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}{% if cluster_wide == false %}/namespaces/#{namespace}{% end %}/{{name.id}}/#{name}/#{subresource}" 1163 | headers = HTTP::Headers{ 1164 | "Content-Type" => "application/merge-patch+json", 1165 | } 1166 | 1167 | response = raw_patch path, {subresource => args}.to_json, headers: headers 1168 | if body = response.body 1169 | ({{type}} | Status).from_json body 1170 | else 1171 | raise "Missing response body" 1172 | end 1173 | end 1174 | 1175 | def delete_{{singular_method_name.id}}(resource : {{type}}) 1176 | delete_{{singular_method_name.id}} name: resource.metadata.name, namespace: resource.metadata.namespace 1177 | end 1178 | 1179 | def delete_{{singular_method_name.id}}(name : String{% if cluster_wide == false %}, namespace : String = "default"{% end %}, *, propagation_policy : PropagationPolicy = :background) 1180 | params = URI::Params{"propagationPolicy" => propagation_policy.to_s} 1181 | path = "/{{prefix.id}}/{{group.id}}/{{version.id}}{% if cluster_wide == false %}/namespaces/#{namespace}{% end %}/{{name.id}}/#{name}?#{params}" 1182 | response = delete path 1183 | JSON.parse response.body 1184 | end 1185 | 1186 | def watch_{{plural_method_name.id}}(resource_version = "0", timeout : Time::Span = 10.minutes, namespace : String? = nil, labels label_selector : String = "") 1187 | params = URI::Params{ 1188 | "watch" => "1", 1189 | "timeoutSeconds" => timeout.total_seconds.to_i64.to_s, 1190 | "labelSelector" => label_selector, 1191 | } 1192 | if namespace 1193 | namespace = "/namespaces/#{namespace}" 1194 | end 1195 | get_response = nil 1196 | loop do 1197 | params["resourceVersion"] = resource_version 1198 | 1199 | return get "/{{prefix.id}}/{{group.id}}/{{version.id}}#{namespace}/{{name.id}}?#{params}" do |response| 1200 | get_response = response 1201 | unless response.success? 1202 | if response.headers["Content-Type"]?.try(&.includes?("application/json")) 1203 | message = JSON.parse(response.body_io) 1204 | else 1205 | message = response.body_io.gets_to_end 1206 | end 1207 | 1208 | raise ClientError.new("#{response.status}: #{message}") 1209 | end 1210 | 1211 | loop do 1212 | watch = Watch({{type}} | Status).from_json IO::Delimited.new(response.body_io, "\n") 1213 | 1214 | # If there's a JSON parsing failure and we loop back around, we'll 1215 | # use this resource version to pick up where we left off. 1216 | if new_version = watch.object.metadata.resource_version.presence 1217 | resource_version = new_version 1218 | end 1219 | 1220 | case obj = watch.object 1221 | when Status 1222 | if match = obj.message.match /too old resource version: \d+ \((\d+)\)/ 1223 | resource_version = match[1] 1224 | end 1225 | # If this is an error of some kind, we don't care we'll just run 1226 | # another request starting from the last resource version we've 1227 | # worked with. 1228 | next 1229 | else 1230 | watch = Watch.new( 1231 | type: watch.type, 1232 | object: obj, 1233 | ) 1234 | end 1235 | 1236 | yield watch 1237 | end 1238 | end 1239 | rescue ex : IO::EOFError 1240 | # Server closed the connection after the timeout 1241 | rescue ex : IO::Error 1242 | @log.warn { ex } 1243 | sleep 1.second # Don't hammer the server 1244 | rescue ex : JSON::ParseException 1245 | # This happens when the watch request times out. This is expected and 1246 | # not an error, so we just ignore it. 1247 | unless ex.message.try &.includes? "Expected BeginObject but was EOF at line 1, column 1" 1248 | @log.warn { "Cannot parse watched object: #{ex}" } 1249 | end 1250 | end 1251 | ensure 1252 | @log.warn { "Exited watch loop for {{plural_method_name.id}}, response = #{get_response.inspect}" } 1253 | end 1254 | end 1255 | {% debug if flag? :debug_define_resource %} 1256 | end 1257 | 1258 | macro import_crd(yaml_file) 1259 | {{ run("./import_crd", yaml_file) }} 1260 | {% debug if flag? :debug_import_crd %} 1261 | end 1262 | 1263 | class Error < ::Exception 1264 | end 1265 | 1266 | class ClientError < Error 1267 | end 1268 | 1269 | class UnexpectedResponse < Error 1270 | end 1271 | 1272 | define_resource "deployments", 1273 | group: "apps", 1274 | type: Deployment 1275 | 1276 | define_resource "statefulsets", 1277 | group: "apps", 1278 | type: Resource(StatefulSet), 1279 | kind: "StatefulSet" 1280 | 1281 | define_resource "cronjobs", 1282 | group: "batch", 1283 | type: Resource(CronJob), 1284 | kind: "CronJob" 1285 | 1286 | define_resource "jobs", 1287 | group: "batch", 1288 | type: Resource(Job), 1289 | kind: "Job" 1290 | 1291 | define_resource "services", 1292 | group: "", 1293 | type: Service, 1294 | prefix: "api" 1295 | 1296 | define_resource "persistentvolumeclaims", 1297 | group: "", 1298 | type: Resource(JSON::Any), # TODO: Write PVC struct, 1299 | prefix: "api", 1300 | kind: "PersistentVolumeClaim" 1301 | 1302 | define_resource "ingresses", 1303 | singular_name: "ingress", 1304 | group: "networking.k8s.io", 1305 | type: Resource(Networking::Ingress), 1306 | kind: "Ingress" 1307 | 1308 | define_resource "pods", 1309 | group: "", 1310 | type: Pod, 1311 | prefix: "api" 1312 | 1313 | struct Config 1314 | include Serializable 1315 | 1316 | field api_version : String 1317 | field kind : String 1318 | field clusters : Array(ClusterEntry) 1319 | field contexts : Array(ContextEntry) 1320 | field current_context : String, 1321 | key: "current-context" 1322 | field preferences : Hash(String, YAML::Any)? 1323 | field users : Array(UserEntry) 1324 | 1325 | struct ClusterEntry 1326 | include Serializable 1327 | 1328 | field cluster : Cluster 1329 | field name : String 1330 | 1331 | struct Cluster 1332 | include Serializable 1333 | 1334 | field certificate_authority_data : String, key: "certificate-authority-data" 1335 | field server : URI 1336 | end 1337 | end 1338 | 1339 | struct ContextEntry 1340 | include Serializable 1341 | 1342 | field context : Context 1343 | field name : String 1344 | 1345 | struct Context 1346 | include Serializable 1347 | 1348 | field cluster : String 1349 | field user : String 1350 | end 1351 | end 1352 | 1353 | struct UserEntry 1354 | include Serializable 1355 | 1356 | field name : String 1357 | field user : User 1358 | 1359 | struct User 1360 | include Serializable 1361 | include YAML::Serializable::Unmapped 1362 | 1363 | field exec : Exec? 1364 | 1365 | field client_certificate_data : String?, key: "client-certificate-data" 1366 | field client_key_data : String?, key: "client-key-data" 1367 | field token_data : String?, key: "token" 1368 | 1369 | def credential 1370 | if exec = self.exec 1371 | output = IO::Memory.new 1372 | Process.run( 1373 | command: exec.command, 1374 | args: exec.args, 1375 | output: output, 1376 | ) 1377 | ExecCredential.from_json output.rewind 1378 | else 1379 | raise "Cannot figure out how to get credentials for #{inspect}" 1380 | end 1381 | end 1382 | 1383 | struct ExecCredential 1384 | include Serializable 1385 | 1386 | field api_version : String 1387 | field kind : String 1388 | field spec : JSON::Any 1389 | field status : Status 1390 | 1391 | struct Status 1392 | include Serializable 1393 | 1394 | field expiration_timestamp : Time 1395 | field token : String 1396 | end 1397 | end 1398 | end 1399 | 1400 | struct Exec 1401 | include Serializable 1402 | 1403 | field api_version : String 1404 | field args : Array(String) { [] of String } 1405 | field command : String 1406 | field env : YAML::Any? 1407 | field interactive_mode : String? 1408 | field? provide_cluster_info : Bool? 1409 | end 1410 | end 1411 | end 1412 | end 1413 | --------------------------------------------------------------------------------