kube_cluster 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +43 -0
  3. data/.github/workflows/tag-gem-version-bump.yml +47 -0
  4. data/.gitignore +2 -0
  5. data/Gemfile.lock +48 -52
  6. data/bin/console +3 -0
  7. data/bin/dev +4 -0
  8. data/docker-compose.yml +26 -0
  9. data/examples/01-basic-redis-pod/manifest.rb +60 -0
  10. data/examples/database/manifest.rb +238 -0
  11. data/examples/version2/demo.rb +87 -0
  12. data/examples/version2/helpers.rb +18 -0
  13. data/examples/version2/my_app.rb +45 -0
  14. data/examples/version2/postgresql.rb +81 -0
  15. data/examples/version2/ruby_on_rails.rb +31 -0
  16. data/examples/web-app/manifest.rb +215 -0
  17. data/flake.lock +3 -3
  18. data/flake.nix +6 -0
  19. data/kube_cluster.gemspec +3 -1
  20. data/lib/kube/cli/cluster.rb +41 -0
  21. data/lib/kube/cluster/connection.rb +18 -0
  22. data/lib/kube/cluster/instance.rb +21 -0
  23. data/lib/kube/cluster/manifest/middleware/annotations.rb +32 -0
  24. data/lib/kube/cluster/manifest/middleware/hpa_for_deployment.rb +109 -0
  25. data/lib/kube/cluster/manifest/middleware/ingress_for_service.rb +89 -0
  26. data/lib/kube/cluster/manifest/middleware/labels.rb +59 -0
  27. data/lib/kube/cluster/manifest/middleware/namespace.rb +31 -0
  28. data/lib/kube/cluster/manifest/middleware/pod_anti_affinity.rb +61 -0
  29. data/lib/kube/cluster/manifest/middleware/resource_preset.rb +64 -0
  30. data/lib/kube/cluster/manifest/middleware/security_context.rb +84 -0
  31. data/lib/kube/cluster/manifest/middleware/service_for_deployment.rb +69 -0
  32. data/lib/kube/cluster/manifest/middleware.rb +178 -0
  33. data/lib/kube/cluster/manifest/stack.rb +56 -0
  34. data/lib/kube/cluster/manifest.rb +76 -0
  35. data/lib/kube/cluster/resource/dirty_tracking.rb +113 -0
  36. data/lib/kube/cluster/resource/persistence.rb +67 -0
  37. data/lib/kube/cluster/resource.rb +21 -0
  38. data/lib/kube/cluster/version.rb +1 -1
  39. data/lib/kube/cluster.rb +13 -7
  40. data/lib/kube/errors.rb +57 -0
  41. metadata +63 -17
  42. data/Rakefile +0 -11
  43. data/TREE_PLAN.md +0 -513
  44. data/bin/generate-command-schema-v1 +0 -44
  45. data/data/kubectl-command-tree-v1-minimal.json +0 -125
  46. data/data/kubectl-command-tree-v1.json +0 -1469
  47. data/examples/quick-repl/docker-compose.yml +0 -52
  48. data/exe/kube_cluster +0 -6
  49. data/lib/kube/cluster/command_node.rb +0 -89
  50. data/lib/kube/cluster/ctl.rb +0 -33
  51. data/lib/kube/cluster/query_builder.rb +0 -35
  52. data/lib/kube/cluster/resource_selector.rb +0 -19
  53. data/lib/kube/cluster/tree_node.rb +0 -51
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module App
4
+ module Helpers
5
+ def match_labels(name:, instance:, component: nil)
6
+ labels = {
7
+ "app.kubernetes.io/name": name,
8
+ "app.kubernetes.io/instance": instance,
9
+ }
10
+ labels[:"app.kubernetes.io/component"] = component if component
11
+ labels
12
+ end
13
+
14
+ def base64(str)
15
+ [str].pack("m0")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ class MyApp < Kube::Cluster::Manifest
6
+ include Helpers
7
+
8
+ Middleware = Kube::Cluster::Manifest::Middleware
9
+
10
+ stack do
11
+ # Generative — produce new resources from existing ones
12
+ use Middleware::ServiceForDeployment
13
+ use Middleware::IngressForService
14
+ use Middleware::HPAForDeployment
15
+
16
+ # Transforms — apply to everything, including generated resources
17
+ use Middleware::Labels, managed_by: "kube_cluster"
18
+ use Middleware::ResourcePreset
19
+ use Middleware::SecurityContext
20
+ use Middleware::PodAntiAffinity
21
+ end
22
+
23
+ attr_reader :domain, :db_domain, :rails_domain, :size
24
+
25
+ def initialize(domain, size: :small, &block)
26
+ super()
27
+ @domain = domain
28
+ @db_domain = "db.#{domain}"
29
+ @rails_domain = "app.#{domain}"
30
+ @size = size
31
+ block.call(self) if block
32
+ end
33
+
34
+ # Labels that encode the manifest-level abstractions.
35
+ # Middleware reads these to apply sizing, security, etc.
36
+ def app_labels(name:, instance:, component: nil)
37
+ labels = {
38
+ "app.kubernetes.io/name": name,
39
+ "app.kubernetes.io/instance": instance,
40
+ "app.kubernetes.io/size": @size.to_s,
41
+ }
42
+ labels[:"app.kubernetes.io/component"] = component if component
43
+ labels
44
+ end
45
+ end
@@ -0,0 +1,81 @@
1
+ class Postgresql < Kube::Cluster::Manifest
2
+
3
+ def initialize(namespace: nil)
4
+ super
5
+ @namespace = namespace
6
+
7
+ self << Namespace.new
8
+ self << StatefulSet.new
9
+ self << Service.new
10
+ self << Secret.new
11
+ end
12
+
13
+ class StatefulSet < Kube::Schema["StatefulSet"]
14
+ metadata.name = db_name
15
+ metadata.namespace = db_ns
16
+ metadata.labels = db_labels
17
+ spec.serviceName = "#{db_name}-headless"
18
+ spec.replicas = 1
19
+ spec.selector.matchLabels = db_match
20
+ spec.template.metadata.labels = db_labels
21
+ spec.template.spec.containers = [
22
+ {
23
+ name: "postgres",
24
+ image: "docker.io/postgres:16.4-alpine",
25
+ ports: [{ name: "tcp-postgresql", containerPort: 5432 }],
26
+ env: [
27
+ { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: db_name, key: "postgres-password" } } },
28
+ { name: "PGDATA", value: "/var/lib/postgresql/data/pgdata" },
29
+ ],
30
+ volumeMounts: [{ name: "data", mountPath: "/var/lib/postgresql/data" }],
31
+ livenessProbe: {
32
+ exec: { command: ["pg_isready", "-U", "postgres"] },
33
+ initialDelaySeconds: 30,
34
+ periodSeconds: 10,
35
+ timeoutSeconds: 5,
36
+ failureThreshold: 6,
37
+ },
38
+ readinessProbe: {
39
+ exec: { command: ["pg_isready", "-U", "postgres"] },
40
+ initialDelaySeconds: 5,
41
+ periodSeconds: 10,
42
+ timeoutSeconds: 5,
43
+ failureThreshold: 6,
44
+ },
45
+ },
46
+ ]
47
+ spec.volumeClaimTemplates = [
48
+ {
49
+ metadata: { name: "data" },
50
+ spec: {
51
+ accessModes: ["ReadWriteOnce"],
52
+ resources: { requests: { storage: "10Gi" } },
53
+ },
54
+ },
55
+ ]
56
+ end
57
+
58
+ class Namespace < Kube::Schema["Namespace"]
59
+ metadata.name = db_ns
60
+ metadata.labels = db_labels.reject { |k, _| k == :"app.kubernetes.io/component" }
61
+ end
62
+
63
+ class Secret < Kube::Schema["Secret"]
64
+ metadata.name = db_name
65
+ metadata.namespace = db_ns
66
+ metadata.labels = db_labels
67
+ self.type = "Opaque"
68
+ self.data = { "postgres-password": m.base64(pg_password) }
69
+ end
70
+
71
+ # Headless service for StatefulSet DNS — explicit because the
72
+ # middleware-generated Service is a regular ClusterIP service.
73
+ class Service < Kube::Schema["Service"]
74
+ metadata.name = "#{db_name}-headless"
75
+ metadata.namespace = db_ns
76
+ metadata.labels = db_labels
77
+ spec.clusterIP = "None"
78
+ spec.selector = db_match
79
+ spec.ports = [{ name: "tcp-postgresql", port: 5432, targetPort: "tcp-postgresql" }]
80
+ end
81
+ end
@@ -0,0 +1,31 @@
1
+ class RubyOnRails < Kube::Schema["Deployment"]
2
+ default do
3
+ metadata.name = name
4
+ metadata.namespace = ns
5
+ metadata.labels = labels.merge(
6
+ "app.kubernetes.io/expose": "app.example.com",
7
+ "app.kubernetes.io/autoscale": "1-5",
8
+ )
9
+ spec.replicas = 1
10
+ spec.selector.matchLabels = m.match_labels(name: name, instance: name)
11
+ spec.template.metadata.labels = labels
12
+ spec.template.spec.containers = [
13
+ {
14
+ name: name,
15
+ image: "ghcr.io/acme/rails-app:2.0",
16
+ ports: [{ name: "http", containerPort: 3000, protocol: "TCP" }],
17
+ envFrom: [{ configMapRef: { name: "#{name}-config" } }],
18
+ livenessProbe: {
19
+ httpGet: { path: "/healthz", port: "http" },
20
+ initialDelaySeconds: 15,
21
+ periodSeconds: 10,
22
+ },
23
+ readinessProbe: {
24
+ httpGet: { path: "/readyz", port: "http" },
25
+ initialDelaySeconds: 5,
26
+ periodSeconds: 5,
27
+ },
28
+ },
29
+ ]
30
+ end
31
+ end
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "kube/schema"
5
+
6
+ MATCH_LABELS = STANDARD_LABELS.slice(
7
+ :"app.kubernetes.io/name",
8
+ :"app.kubernetes.io/instance",
9
+ )
10
+
11
+ REGISTRY = "docker.io"
12
+ REPOSITORY = "nginx"
13
+ TAG = "1.27.3-alpine"
14
+ IMAGE = "#{REGISTRY}/#{REPOSITORY}:#{TAG}"
15
+
16
+ RESOURCE_PRESETS = {
17
+ "nano": { requests: { cpu: "100m", memory: "128Mi" }, limits: { cpu: "150m", memory: "192Mi" } },
18
+ "micro": { requests: { cpu: "250m", memory: "256Mi" }, limits: { cpu: "375m", memory: "384Mi" } },
19
+ "small": { requests: { cpu: "500m", memory: "512Mi" }, limits: { cpu: "750m", memory: "768Mi" } },
20
+ "medium": { requests: { cpu: "500m", memory: "1024Mi" }, limits: { cpu: "750m", memory: "1536Mi" } },
21
+ "large": { requests: { cpu: "1.0", memory: "2048Mi" }, limits: { cpu: "1.5", memory: "3072Mi" } },
22
+ "xlarge": { requests: { cpu: "1.0", memory: "3072Mi" }, limits: { cpu: "3.0", memory: "6144Mi" } },
23
+ "2xlarge": { requests: { cpu: "1.0", memory: "3072Mi" }, limits: { cpu: "6.0", memory: "12288Mi" } },
24
+ }
25
+
26
+ module AutoConfig
27
+ end
28
+
29
+ class MyApp < Kube::Schema::Manifest
30
+ include Helpers
31
+ include AutoConfig
32
+
33
+ def initialize(namespace: DEFAULT_NAMESPACE, labels: STANDARD_LABELS)
34
+ @app_name = "web-app"
35
+ @fullname = "#{RELEASE_NAME}-#{APP_NAME}"[0, 63].chomp("-")
36
+ @namespace = namespace
37
+
38
+ @labels = {
39
+ "app.kubernetes.io/name": @app_name
40
+ "app.kubernetes.io/managed-by": "kube_cluster",
41
+ }
42
+ end
43
+
44
+ stack do
45
+ use Middleware::Namespace
46
+ use Middleware::Labels
47
+ end
48
+
49
+ class Namespace < Kube::Schema["Namespace"]
50
+ def initialize(namespace:)
51
+ build {
52
+ metadata.name = namespace
53
+ }
54
+ end
55
+ end
56
+
57
+ class ConfigMap < Kube::Schema["ConfigMap"]
58
+ def initialize(namespace:)
59
+ build {
60
+ metadata.name = "#{namespace}-config"
61
+ spec.data = {
62
+ RAILS_ENV: "production",
63
+ LOG_LEVEL: "info",
64
+ WORKERS: "4",
65
+ PORT: "3000",
66
+ }
67
+ }
68
+ end
69
+ end
70
+
71
+ class Deployment < Kube::Schema["Deployment"]
72
+ def initialize(namespace:)
73
+ build {
74
+ metadata.name = namespace
75
+
76
+ spec.replicas = 3
77
+ spec.selector.matchLabels = MATCH_LABELS
78
+
79
+ spec.template.metadata.labels = STANDARD_LABELS
80
+ spec.template.metadata.annotations = {
81
+ # Checksum pattern from _utils.tpl -- triggers rolling restart on config change
82
+ "checksum/config": "{{ sha256sum of configmap data }}",
83
+ }
84
+
85
+ spec.template.spec.containers = [
86
+ {
87
+ name: APP_NAME,
88
+ image: IMAGE,
89
+ ports: [{ name: "http", containerPort: 3000, protocol: "TCP" }],
90
+ resources: RESOURCES,
91
+ env: [
92
+ { name: "PORT", value: "3000" },
93
+ ],
94
+ envFrom: [
95
+ { configMapRef: { name: "#{FULLNAME}-config" } },
96
+ ],
97
+ livenessProbe: {
98
+ httpGet: { path: "/healthz", port: "http" },
99
+ initialDelaySeconds: 15,
100
+ periodSeconds: 10,
101
+ },
102
+ readinessProbe: {
103
+ httpGet: { path: "/readyz", port: "http" },
104
+ initialDelaySeconds: 5,
105
+ periodSeconds: 5,
106
+ },
107
+ },
108
+ ]
109
+
110
+ # Pod anti-affinity (from _affinities.tpl)
111
+ # Soft anti-affinity: prefer spreading pods across nodes but don't enforce it
112
+ spec.template.spec.affinity = {
113
+ podAntiAffinity: {
114
+ preferredDuringSchedulingIgnoredDuringExecution: [
115
+ {
116
+ weight: 1,
117
+ podAffinityTerm: {
118
+ labelSelector: {
119
+ matchLabels: MATCH_LABELS,
120
+ },
121
+ topologyKey: "kubernetes.io/hostname",
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ }
127
+ }
128
+ end
129
+ end
130
+
131
+ class Service < Kube::Schema["Service"]
132
+ def initialize(namespace:)
133
+ build {
134
+ metadata.name = namespace
135
+
136
+ spec.selector = MATCH_LABELS
137
+ spec.ports = [
138
+ { name: "http", port: 80, targetPort: "http", protocol: "TCP" },
139
+ ]
140
+ }
141
+ end
142
+ end
143
+
144
+ class Ingress < Kube::Schema["Ingress"]
145
+ def initialize(namespace:)
146
+ build {
147
+ metadata.name = namespace
148
+ metadata.annotations = {
149
+ "cert-manager.io/cluster-issuer": "letsencrypt-prod",
150
+ "nginx.ingress.kubernetes.io/ssl-redirect": "true",
151
+ }
152
+
153
+ spec.ingressClassName = "nginx"
154
+ spec.tls = [
155
+ {
156
+ hosts: ["app.example.com"],
157
+ secretName: "#{namespace}-tls",
158
+ },
159
+ ]
160
+ spec.rules = [
161
+ {
162
+ host: "app.example.com",
163
+ http: {
164
+ paths: [
165
+ {
166
+ path: "/",
167
+ pathType: "Prefix",
168
+ backend: {
169
+ service: {
170
+ name: FULLNAME,
171
+ port: { name: "http" },
172
+ },
173
+ },
174
+ },
175
+ ],
176
+ },
177
+ },
178
+ ]
179
+ }
180
+ end
181
+ end
182
+
183
+ class HorizontalPodAutoscaler < Kube::Schema["HorizontalPodAutoscaler"]
184
+ def initialize(namespace:)
185
+ build {
186
+ metadata.name = namespace
187
+
188
+ spec.scaleTargetRef.apiVersion = "apps/v1"
189
+ spec.scaleTargetRef.kind = "Deployment"
190
+ spec.scaleTargetRef.name = namespace
191
+
192
+ spec.minReplicas = 3
193
+ spec.maxReplicas = 10
194
+ spec.metrics = [
195
+ {
196
+ type: "Resource",
197
+ resource: {
198
+ name: "cpu",
199
+ target: { type: "Utilization", averageUtilization: 75 },
200
+ },
201
+ },
202
+ {
203
+ type: "Resource",
204
+ resource: {
205
+ name: "memory",
206
+ target: { type: "Utilization", averageUtilization: 80 },
207
+ },
208
+ },
209
+ ]
210
+ }
211
+ end
212
+ end
213
+ end
214
+
215
+ puts MyApp.new.to_yaml
data/flake.lock CHANGED
@@ -2,11 +2,11 @@
2
2
  "nodes": {
3
3
  "nixpkgs": {
4
4
  "locked": {
5
- "lastModified": 1773821835,
6
- "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
5
+ "lastModified": 1776169885,
6
+ "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
7
7
  "owner": "NixOS",
8
8
  "repo": "nixpkgs",
9
- "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
9
+ "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
10
10
  "type": "github"
11
11
  },
12
12
  "original": {
data/flake.nix CHANGED
@@ -10,6 +10,10 @@
10
10
  let
11
11
  pkgs = nixpkgs.legacyPackages.${system};
12
12
  ruby = pkgs.ruby_3_4; # Specify version
13
+ kubectlWithKubeconfig = pkgs.writeShellScriptBin "kubectl" ''
14
+ #!${pkgs.bash}/bin/bash
15
+ KUBECONFIG="$PWD/kubeconfig.yaml" ${pkgs.kubectl}/bin/kubectl "$@"
16
+ '';
13
17
  in
14
18
  {
15
19
  devShells.default = pkgs.mkShell {
@@ -21,6 +25,7 @@
21
25
  ruby
22
26
  pkgs.libyaml # psych gem
23
27
  pkgs.openssl # openssl gem
28
+ kubectlWithKubeconfig
24
29
  ];
25
30
 
26
31
  shellHook = ''
@@ -29,6 +34,7 @@
29
34
  export PATH="$GEM_HOME/bin:$PATH"
30
35
  export BUNDLE_PATH="$GEM_HOME"
31
36
  export BUNDLE_BIN="$GEM_HOME/bin"
37
+ export KUBECONFIG="$PWD/kubeconfig.yaml"
32
38
  '';
33
39
  };
34
40
  }
data/kube_cluster.gemspec CHANGED
@@ -32,5 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.add_development_dependency "rake", "~> 13.0"
33
33
  spec.add_development_dependency "rubocop", "~> 1.21"
34
34
 
35
- spec.add_dependency "kube_schema", "~> 1.0"
35
+ spec.add_dependency "kube_schema", "~> 1.2.0"
36
+ spec.add_dependency "kube_kit", "> 0"
37
+ spec.add_dependency "kube_kubectl", "~> 2.0.0"
36
38
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kube/cli"
4
+ require "kube/cluster"
5
+
6
+ Kube::CLI.register "cluster", ->(argv) {
7
+ subcommand = argv.shift
8
+
9
+ case subcommand
10
+ when "connect", nil
11
+ kubeconfig = ENV["KUBECONFIG"]
12
+
13
+ # Parse --kubeconfig flag
14
+ if (idx = argv.index("--kubeconfig"))
15
+ kubeconfig = argv[idx + 1]
16
+ argv.slice!(idx, 2)
17
+ elsif (flag = argv.find { |a| a.start_with?("--kubeconfig=") })
18
+ kubeconfig = flag.split("=", 2).last
19
+ argv.delete(flag)
20
+ end
21
+
22
+ if kubeconfig.nil?
23
+ $stderr.puts "kube cluster connect: missing --kubeconfig or KUBECONFIG env var"
24
+ exit 1
25
+ end
26
+
27
+ instance = Kube::Cluster.connect(kubeconfig: kubeconfig)
28
+ puts instance.inspect
29
+
30
+ when "help", "--help", "-h"
31
+ puts "Usage: kube cluster <subcommand> [options]"
32
+ puts
33
+ puts "Subcommands:"
34
+ puts " connect Connect to a cluster (--kubeconfig=PATH or KUBECONFIG env)"
35
+ puts
36
+
37
+ else
38
+ $stderr.puts "kube cluster: unknown subcommand '#{subcommand}'"
39
+ exit 1
40
+ end
41
+ }, description: "Manage cluster connections"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Cluster
5
+ class Connection
6
+ attr_reader :kubeconfig, :ctl
7
+
8
+ def initialize(kubeconfig:)
9
+ @kubeconfig = kubeconfig
10
+ @ctl = Kube::Ctl::Instance.new(kubeconfig: kubeconfig)
11
+ end
12
+
13
+ def inspect
14
+ "#<#{self.class.name} kubeconfig=#{@kubeconfig.inspect}>"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Cluster
5
+ class Instance
6
+ attr_reader :kubeconfig
7
+
8
+ def initialize(kubeconfig:)
9
+ @kubeconfig = kubeconfig
10
+ end
11
+
12
+ def connection
13
+ @connection ||= Connection.new(kubeconfig: @kubeconfig)
14
+ end
15
+
16
+ def inspect
17
+ "#<#{self.class.name} kubeconfig=#{@kubeconfig.inspect}>"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Cluster
5
+ class Manifest < Kube::Schema::Manifest
6
+ class Middleware
7
+ # Merges annotations into +metadata.annotations+ on every resource.
8
+ # Existing annotations are preserved; the supplied annotations act
9
+ # as defaults that can be overridden per-resource.
10
+ #
11
+ # stack do
12
+ # use Middleware::Annotations,
13
+ # "prometheus.io/scrape": "true",
14
+ # "prometheus.io/port": "9090"
15
+ # end
16
+ #
17
+ class Annotations < Middleware
18
+ def initialize(**annotations)
19
+ @annotations = annotations.transform_keys(&:to_sym).transform_values(&:to_s)
20
+ end
21
+
22
+ def call(resource)
23
+ h = resource.to_h
24
+ h[:metadata] ||= {}
25
+ h[:metadata][:annotations] = @annotations.merge(h[:metadata][:annotations] || {})
26
+ rebuild(resource, h)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Cluster
5
+ class Manifest < Kube::Schema::Manifest
6
+ class Middleware
7
+ # Generates a HorizontalPodAutoscaler for every pod-bearing
8
+ # resource that carries the +app.kubernetes.io/autoscale+ label.
9
+ #
10
+ # The label value encodes the min and max replicas as "min-max":
11
+ #
12
+ # metadata.labels = { "app.kubernetes.io/autoscale": "1-5" }
13
+ #
14
+ # Options:
15
+ # cpu: — target CPU utilization percentage (default: 75)
16
+ # memory: — target memory utilization percentage (default: 80)
17
+ #
18
+ # stack do
19
+ # use Middleware::HPAForDeployment
20
+ # use Middleware::HPAForDeployment, cpu: 60, memory: 70
21
+ # end
22
+ #
23
+ class HPAForDeployment < Middleware
24
+ LABEL = :"app.kubernetes.io/autoscale"
25
+
26
+ def initialize(cpu: 75, memory: 80)
27
+ @cpu = cpu
28
+ @memory = memory
29
+ end
30
+
31
+ def call(resource)
32
+ return resource unless pod_bearing?(resource)
33
+
34
+ value = label(resource, LABEL)
35
+ return resource unless value
36
+
37
+ min, max = parse_range(value)
38
+
39
+ h = resource.to_h
40
+ name = h.dig(:metadata, :name)
41
+ namespace = h.dig(:metadata, :namespace)
42
+ labels = h.dig(:metadata, :labels) || {}
43
+ api_version = h[:apiVersion] || "apps/v1"
44
+ resource_kind = kind(resource)
45
+
46
+ # Capture ivars as locals — the block runs via instance_exec
47
+ # on a BlackHoleStruct, so @ivars would resolve on the BHS.
48
+ cpu_target = @cpu
49
+ memory_target = @memory
50
+
51
+ hpa = Kube::Schema["HorizontalPodAutoscaler"].new {
52
+ metadata.name = name
53
+ metadata.namespace = namespace if namespace
54
+ metadata.labels = labels.reject { |k, _| k == LABEL }
55
+
56
+ spec.scaleTargetRef = {
57
+ apiVersion: api_version,
58
+ kind: resource_kind,
59
+ name: name,
60
+ }
61
+ spec.minReplicas = min
62
+ spec.maxReplicas = max
63
+ spec.metrics = [
64
+ {
65
+ type: "Resource",
66
+ resource: {
67
+ name: "cpu",
68
+ target: { type: "Utilization", averageUtilization: cpu_target },
69
+ },
70
+ },
71
+ {
72
+ type: "Resource",
73
+ resource: {
74
+ name: "memory",
75
+ target: { type: "Utilization", averageUtilization: memory_target },
76
+ },
77
+ },
78
+ ]
79
+ }
80
+
81
+ [resource, hpa]
82
+ end
83
+
84
+ private
85
+
86
+ def parse_range(value)
87
+ parts = value.to_s.split("-", 2)
88
+
89
+ unless parts.length == 2
90
+ raise ArgumentError,
91
+ "Invalid autoscale label: #{value.inspect}. Expected format: \"min-max\" (e.g. \"1-5\")"
92
+ end
93
+
94
+ min = Integer(parts[0])
95
+ max = Integer(parts[1])
96
+
97
+ unless min > 0 && max >= min
98
+ raise ArgumentError,
99
+ "Invalid autoscale range: min=#{min}, max=#{max}. " \
100
+ "min must be > 0 and max must be >= min."
101
+ end
102
+
103
+ [min, max]
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end