kube_schema 1.3.10 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec9dd9097e79e7347dad7507a4670448598cbabf88d5347f4f54304cbdea582e
4
- data.tar.gz: 0d0fc8aad770317f4e1a666e62b5a884433165dd84aebafcb7ba759c8e37ede2
3
+ metadata.gz: fda1529e32d0c67ab09583321fb978e79a6458dd728690fabca903481cbc0091
4
+ data.tar.gz: 80af0b145d5818bf71fa1b7bd71cdffdd716c61a75318c1af9ca6216b8a235bf
5
5
  SHA512:
6
- metadata.gz: 9d1b2444bed18ac8c17c9e383e4f5e063b032cde1ac18a6528e8de363af63f750ff00b0010198165f73e0c0c782f2fdf4f9b69afb0e3ac0051272e993d97d507
7
- data.tar.gz: 7fdfcd1388bfe2eda4097602ff9397c3564b84921133806f7784a947e6a7fab9a88ece0d6497a24a6327924f6cd2ba5be948b4ae878cbaa43009e7f6bd0a067a
6
+ metadata.gz: 170f76c75099ec6ff86385286d5a3a46f2a0277b28c8c6ab6d1e6a4beff274f4509824fc6638d67e9ad6f96b2b47d56c9dce710fcbc752e07ffad1a56d412806
7
+ data.tar.gz: e381f258ac28d30e5ca7fa2ca64d222afc9fc7cf298a445ce29bd5afed171f1de55166012c986a934ad140a67f70d275aca58e4de596108241544461a1741222
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kube_schema (1.3.10)
4
+ kube_schema (1.4.0)
5
5
  json_schemer (~> 2.5.0)
6
6
  rubyshell (~> 1.5.0)
7
7
 
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Demonstrates Kube::Schema::SubSpec — typed, schema-validated wrappers
5
+ # for non-resource Kubernetes definitions (Container, Volume, Probe, etc.).
6
+ #
7
+ # SubSpec objects validate against the OpenAPI JSON Schema and auto-coerce
8
+ # to plain hashes when placed inside Resource specs.
9
+ #
10
+ # Run: bundle exec ruby examples/sub_specs.rb
11
+
12
+ require_relative "../lib/kube/schema"
13
+
14
+ SubSpec = Kube::Schema::SubSpec
15
+ Manifest = Kube::Schema::Manifest
16
+
17
+ puts "=" * 60
18
+ puts "Kube::Schema::SubSpec Examples"
19
+ puts "=" * 60
20
+ puts
21
+
22
+
23
+ # ══════════════════════════════════════════════════════════════
24
+ # 1. CONTAINER — the core use case
25
+ # ══════════════════════════════════════════════════════════════
26
+
27
+ app = SubSpec["Container"].new {
28
+ self.name = "app"
29
+ self.image = "myapp:1.4.2"
30
+ self.imagePullPolicy = "IfNotPresent"
31
+ self.command = ["/usr/bin/myapp"]
32
+ self.args = ["--config", "/etc/myapp/config.yaml"]
33
+ self.ports = [
34
+ { name: "http", containerPort: 8080, protocol: "TCP" },
35
+ { name: "metrics", containerPort: 9090, protocol: "TCP" }
36
+ ]
37
+ self.env = [
38
+ { name: "LOG_LEVEL", value: "info" },
39
+ { name: "WORKERS", value: "4" },
40
+ { name: "DB_PASSWORD", valueFrom: { secretKeyRef: { name: "db-creds", key: "password" } } }
41
+ ]
42
+ self.resources.requests = { cpu: "100m", memory: "128Mi" }
43
+ self.resources.limits = { cpu: "500m", memory: "256Mi" }
44
+ self.volumeMounts = [
45
+ { name: "config", mountPath: "/etc/myapp", readOnly: true },
46
+ { name: "data", mountPath: "/var/data" }
47
+ ]
48
+ self.livenessProbe = {
49
+ httpGet: { path: "/healthz", port: 8080 },
50
+ initialDelaySeconds: 10,
51
+ periodSeconds: 30
52
+ }
53
+ self.readinessProbe = {
54
+ httpGet: { path: "/ready", port: 8080 },
55
+ initialDelaySeconds: 5,
56
+ periodSeconds: 10
57
+ }
58
+ self.securityContext.runAsNonRoot = true
59
+ self.securityContext.readOnlyRootFilesystem = true
60
+ self.securityContext.allowPrivilegeEscalation = false
61
+ }
62
+
63
+ puts "1. Container: #{app.name}"
64
+ puts " image: #{app.image}"
65
+ puts " ports: #{app.ports.map { |p| p[:name] }.join(", ")}"
66
+ puts " valid? #{app.valid?}"
67
+ puts
68
+
69
+
70
+ # ══════════════════════════════════════════════════════════════
71
+ # 2. CONTAINER PORT — typed port definitions
72
+ # ══════════════════════════════════════════════════════════════
73
+
74
+ http_port = SubSpec["ContainerPort"].new {
75
+ self.name = "http"
76
+ self.containerPort = 8080
77
+ self.protocol = "TCP"
78
+ }
79
+
80
+ grpc_port = SubSpec["ContainerPort"].new(
81
+ name: "grpc", containerPort: 9090, protocol: "TCP"
82
+ )
83
+
84
+ puts "2. ContainerPorts:"
85
+ puts " - #{http_port.name}: #{http_port.containerPort}"
86
+ puts " - #{grpc_port.name}: #{grpc_port.containerPort}"
87
+ puts
88
+
89
+
90
+ # ══════════════════════════════════════════════════════════════
91
+ # 3. ENV VAR — plain values and valueFrom references
92
+ # ══════════════════════════════════════════════════════════════
93
+
94
+ env_plain = SubSpec["EnvVar"].new(name: "APP_ENV", value: "production")
95
+
96
+ env_secret = SubSpec["EnvVar"].new {
97
+ self.name = "DB_PASSWORD"
98
+ self.valueFrom.secretKeyRef = { name: "db-creds", key: "password" }
99
+ }
100
+
101
+ env_configmap = SubSpec["EnvVar"].new {
102
+ self.name = "LOG_FORMAT"
103
+ self.valueFrom.configMapKeyRef = { name: "app-config", key: "log_format" }
104
+ }
105
+
106
+ env_field = SubSpec["EnvVar"].new {
107
+ self.name = "POD_NAME"
108
+ self.valueFrom.fieldRef = { fieldPath: "metadata.name" }
109
+ }
110
+
111
+ puts "3. EnvVars:"
112
+ [env_plain, env_secret, env_configmap, env_field].each do |e|
113
+ puts " - #{e.name}: valid? #{e.valid?}"
114
+ end
115
+ puts
116
+
117
+
118
+ # ══════════════════════════════════════════════════════════════
119
+ # 4. VOLUME + VOLUME MOUNT — paired definitions
120
+ # ══════════════════════════════════════════════════════════════
121
+
122
+ config_volume = SubSpec["Volume"].new {
123
+ self.name = "config"
124
+ self.configMap = { name: "app-config" }
125
+ }
126
+
127
+ secret_volume = SubSpec["Volume"].new {
128
+ self.name = "tls-certs"
129
+ self.secret = { secretName: "tls-secret" }
130
+ }
131
+
132
+ data_volume = SubSpec["Volume"].new {
133
+ self.name = "data"
134
+ self.emptyDir = { sizeLimit: "1Gi" }
135
+ }
136
+
137
+ pvc_volume = SubSpec["Volume"].new(
138
+ name: "storage",
139
+ persistentVolumeClaim: { claimName: "app-pvc" }
140
+ )
141
+
142
+ config_mount = SubSpec["VolumeMount"].new(
143
+ name: "config", mountPath: "/etc/app", readOnly: true
144
+ )
145
+
146
+ tls_mount = SubSpec["VolumeMount"].new {
147
+ self.name = "tls-certs"
148
+ self.mountPath = "/etc/tls"
149
+ self.readOnly = true
150
+ }
151
+
152
+ data_mount = SubSpec["VolumeMount"].new(
153
+ name: "data", mountPath: "/var/data"
154
+ )
155
+
156
+ puts "4. Volumes:"
157
+ [config_volume, secret_volume, data_volume, pvc_volume].each do |v|
158
+ puts " - #{v.name}: valid? #{v.valid?}"
159
+ end
160
+ puts " Mounts:"
161
+ [config_mount, tls_mount, data_mount].each do |m|
162
+ puts " - #{m.name} -> #{m.mountPath}"
163
+ end
164
+ puts
165
+
166
+
167
+ # ══════════════════════════════════════════════════════════════
168
+ # 5. PROBE — liveness, readiness, startup
169
+ # ══════════════════════════════════════════════════════════════
170
+
171
+ http_probe = SubSpec["Probe"].new {
172
+ self.httpGet = { path: "/healthz", port: 8080 }
173
+ self.initialDelaySeconds = 15
174
+ self.periodSeconds = 20
175
+ self.timeoutSeconds = 3
176
+ self.failureThreshold = 3
177
+ }
178
+
179
+ tcp_probe = SubSpec["Probe"].new {
180
+ self.tcpSocket = { port: 5432 }
181
+ self.initialDelaySeconds = 5
182
+ self.periodSeconds = 10
183
+ }
184
+
185
+ exec_probe = SubSpec["Probe"].new {
186
+ self.exec = { command: ["/bin/sh", "-c", "pg_isready -U postgres"] }
187
+ self.initialDelaySeconds = 10
188
+ self.periodSeconds = 30
189
+ }
190
+
191
+ grpc_probe = SubSpec["Probe"].new {
192
+ self.grpc = { port: 50051 }
193
+ self.periodSeconds = 10
194
+ }
195
+
196
+ puts "5. Probes:"
197
+ puts " - HTTP: valid? #{http_probe.valid?}"
198
+ puts " - TCP: valid? #{tcp_probe.valid?}"
199
+ puts " - Exec: valid? #{exec_probe.valid?}"
200
+ puts " - gRPC: valid? #{grpc_probe.valid?}"
201
+ puts
202
+
203
+
204
+ # ══════════════════════════════════════════════════════════════
205
+ # 6. SECURITY CONTEXT — hardened container
206
+ # ══════════════════════════════════════════════════════════════
207
+
208
+ hardened = SubSpec["SecurityContext"].new {
209
+ self.runAsNonRoot = true
210
+ self.runAsUser = 1000
211
+ self.runAsGroup = 1000
212
+ self.readOnlyRootFilesystem = true
213
+ self.allowPrivilegeEscalation = false
214
+ self.capabilities = { drop: ["ALL"] }
215
+ self.seccompProfile = { type: "RuntimeDefault" }
216
+ }
217
+
218
+ puts "6. SecurityContext:"
219
+ puts " runAsUser: #{hardened.runAsUser}"
220
+ puts " readOnlyRootFilesystem: #{hardened.readOnlyRootFilesystem}"
221
+ puts " valid? #{hardened.valid?}"
222
+ puts
223
+
224
+
225
+ # ══════════════════════════════════════════════════════════════
226
+ # 7. TOLERATION + TOPOLOGY SPREAD CONSTRAINT
227
+ # ══════════════════════════════════════════════════════════════
228
+
229
+ gpu_toleration = SubSpec["Toleration"].new(
230
+ key: "nvidia.com/gpu", operator: "Exists", effect: "NoSchedule"
231
+ )
232
+
233
+ spot_toleration = SubSpec["Toleration"].new {
234
+ self.key = "kubernetes.azure.com/scalesetpriority"
235
+ self.operator = "Equal"
236
+ self.value = "spot"
237
+ self.effect = "NoSchedule"
238
+ }
239
+
240
+ zone_spread = SubSpec["TopologySpreadConstraint"].new {
241
+ self.maxSkew = 1
242
+ self.topologyKey = "topology.kubernetes.io/zone"
243
+ self.whenUnsatisfiable = "DoNotSchedule"
244
+ self.labelSelector = { matchLabels: { app: "web" } }
245
+ }
246
+
247
+ puts "7. Scheduling:"
248
+ puts " Tolerations:"
249
+ puts " - #{gpu_toleration.key}: valid? #{gpu_toleration.valid?}"
250
+ puts " - #{spot_toleration.key}: valid? #{spot_toleration.valid?}"
251
+ puts " TopologySpreadConstraint:"
252
+ puts " - #{zone_spread.topologyKey}: valid? #{zone_spread.valid?}"
253
+ puts
254
+
255
+
256
+ # ══════════════════════════════════════════════════════════════
257
+ # 8. SERVICE PORT — for Service specs
258
+ # ══════════════════════════════════════════════════════════════
259
+
260
+ svc_http = SubSpec["ServicePort"].new(
261
+ name: "http", port: 80, targetPort: 8080, protocol: "TCP"
262
+ )
263
+
264
+ svc_https = SubSpec["ServicePort"].new {
265
+ self.name = "https"
266
+ self.port = 443
267
+ self.targetPort = 8443
268
+ self.protocol = "TCP"
269
+ self.appProtocol = "kubernetes.io/h2c"
270
+ }
271
+
272
+ puts "8. ServicePorts:"
273
+ puts " - #{svc_http.name}: #{svc_http.port} -> #{svc_http.targetPort}"
274
+ puts " - #{svc_https.name}: #{svc_https.port} -> #{svc_https.targetPort}"
275
+ puts
276
+
277
+
278
+ # ══════════════════════════════════════════════════════════════
279
+ # 9. COMPOSING A FULL DEPLOYMENT — tying it all together
280
+ # ══════════════════════════════════════════════════════════════
281
+
282
+ # Build typed sub-specs
283
+ web_container = SubSpec["Container"].new {
284
+ self.name = "web"
285
+ self.image = "nginx:1.27-alpine"
286
+ self.ports = [http_port.to_h]
287
+ self.resources.requests = { cpu: "50m", memory: "64Mi" }
288
+ self.resources.limits = { cpu: "200m", memory: "128Mi" }
289
+ self.volumeMounts = [config_mount.to_h, tls_mount.to_h]
290
+ self.livenessProbe = http_probe.to_h
291
+ self.readinessProbe = http_probe.to_h
292
+ self.securityContext = hardened.to_h
293
+ }
294
+
295
+ sidecar = SubSpec["Container"].new {
296
+ self.name = "log-shipper"
297
+ self.image = "fluent/fluent-bit:3.2"
298
+ self.resources.requests = { cpu: "25m", memory: "32Mi" }
299
+ self.resources.limits = { cpu: "100m", memory: "64Mi" }
300
+ self.volumeMounts = [data_mount.to_h]
301
+ self.securityContext = hardened.to_h
302
+ }
303
+
304
+ # Compose into a Deployment — SubSpec instances auto-coerce
305
+ deployment = Kube::Schema["Deployment"].new {
306
+ metadata.name = "web"
307
+ metadata.namespace = "production"
308
+ metadata.labels = { app: "web", tier: "frontend" }
309
+
310
+ spec.replicas = 3
311
+ spec.selector.matchLabels = { app: "web" }
312
+ spec.strategy.type = "RollingUpdate"
313
+ spec.strategy.rollingUpdate = { maxSurge: 1, maxUnavailable: 0 }
314
+
315
+ spec.template.metadata.labels = { app: "web", tier: "frontend" }
316
+
317
+ # Auto-coercion: SubSpec instances become plain hashes automatically
318
+ spec.template.spec.containers = [web_container, sidecar]
319
+ spec.template.spec.volumes = [config_volume, secret_volume, data_volume]
320
+
321
+ spec.template.spec.tolerations = [spot_toleration]
322
+ spec.template.spec.topologySpreadConstraints = [zone_spread]
323
+
324
+ spec.template.spec.securityContext.fsGroup = 1000
325
+ spec.template.spec.securityContext.runAsGroup = 1000
326
+ spec.template.spec.serviceAccountName = "web"
327
+ spec.template.spec.terminationGracePeriodSeconds = 30
328
+ }
329
+
330
+ # Also compose a Service using typed ServicePorts
331
+ service = Kube::Schema["Service"].new {
332
+ metadata.name = "web"
333
+ metadata.namespace = "production"
334
+ metadata.labels = { app: "web" }
335
+ spec.selector = { app: "web" }
336
+ spec.type = "ClusterIP"
337
+ spec.ports = [svc_http, svc_https]
338
+ }
339
+
340
+ puts "9. Composed resources:"
341
+ puts " Deployment: #{deployment.metadata[:name]} (#{deployment.spec[:replicas]} replicas)"
342
+ puts " - containers: #{deployment.to_h[:spec][:template][:spec][:containers].map { |c| c[:name] }.join(", ")}"
343
+ puts " - volumes: #{deployment.to_h[:spec][:template][:spec][:volumes].map { |v| v[:name] }.join(", ")}"
344
+ puts " - valid? #{deployment.valid?}"
345
+ puts
346
+ puts " Service: #{service.metadata[:name]}"
347
+ puts " - ports: #{service.to_h[:spec][:ports].map { |p| "#{p[:name]}:#{p[:port]}" }.join(", ")}"
348
+ puts " - valid? #{service.valid?}"
349
+ puts
350
+
351
+
352
+ # ══════════════════════════════════════════════════════════════
353
+ # 10. VALIDATION — catching errors on sub-specs
354
+ # ══════════════════════════════════════════════════════════════
355
+
356
+ puts "10. Validation examples:"
357
+
358
+ # Missing required field: Container requires "name"
359
+ bad_container = SubSpec["Container"].new(image: "nginx")
360
+ puts " Container without name: valid? #{bad_container.valid?}"
361
+
362
+ begin
363
+ bad_container.valid!
364
+ rescue Kube::ValidationError => e
365
+ puts " Error: #{e.errors.length} error(s)"
366
+ puts " #{e.message.lines.grep(/required/).first&.strip}"
367
+ end
368
+ puts
369
+
370
+ # Wrong type: ports should be an array
371
+ bad_ports = SubSpec["Container"].new(name: "app", ports: "not-an-array")
372
+ puts " Container with string ports: valid? #{bad_ports.valid?}"
373
+ puts
374
+
375
+ # VolumeMount missing required fields
376
+ bad_mount = SubSpec["VolumeMount"].new(name: "data")
377
+ puts " VolumeMount without mountPath: valid? #{bad_mount.valid?}"
378
+
379
+ begin
380
+ bad_mount.valid!
381
+ rescue Kube::ValidationError => e
382
+ puts " #{e.message.lines.grep(/required/).first&.strip}"
383
+ end
384
+ puts
385
+
386
+
387
+ # ══════════════════════════════════════════════════════════════
388
+ # 11. DISCOVERY — what sub-specs are available?
389
+ # ══════════════════════════════════════════════════════════════
390
+
391
+ instance = Kube::Schema::Instance.new("1.34")
392
+ all_defs = instance.list_definitions
393
+
394
+ puts "11. Discovery:"
395
+ puts " Total definitions available: #{all_defs.length}"
396
+ puts " Container-related:"
397
+ all_defs.select { |d| d.include?("Container") }.each { |d| puts " - #{d}" }
398
+ puts " Volume-related:"
399
+ all_defs.select { |d| d.start_with?("Volume") }.each { |d| puts " - #{d}" }
400
+ puts
401
+
402
+
403
+ # ══════════════════════════════════════════════════════════════
404
+ # 12. YAML OUTPUT
405
+ # ══════════════════════════════════════════════════════════════
406
+
407
+ manifest = Manifest.new(deployment, service)
408
+
409
+ puts "=" * 60
410
+ puts "YAML Output:"
411
+ puts "=" * 60
412
+ puts manifest.to_yaml
@@ -27,6 +27,7 @@ module Kube
27
27
 
28
28
  def initialize(version)
29
29
  @resource_classes = {}
30
+ @sub_spec_classes = {}
30
31
 
31
32
  unless Gem::Version.correct?(version)
32
33
  raise UnknownVersionError,
@@ -71,6 +72,39 @@ module Kube
71
72
  (gvk_index.keys + Schema.custom_schemas.keys).uniq.sort
72
73
  end
73
74
 
75
+ # Look up a sub-spec definition by short name (e.g. "Container",
76
+ # "ContainerPort", "Volume", "Probe"). Returns a class that
77
+ # inherits from Kube::Schema::SubSpec.
78
+ #
79
+ # Also accepts a full definition key like
80
+ # "io.k8s.api.core.v1.Container" for disambiguation.
81
+ #
82
+ # instance.sub_spec("Container") # => SubSpec subclass
83
+ # instance.sub_spec("ContainerPort") # => SubSpec subclass
84
+ #
85
+ def sub_spec(name)
86
+ @sub_spec_classes[name] ||= begin
87
+ definition_key = find_definition_key(name)
88
+
89
+ if definition_key.nil?
90
+ raise "No definition found for #{name}!" \
91
+ "\nUse #list_definitions to see available definitions for v#{version}."
92
+ end
93
+
94
+ ref_schema = schemer.ref("#/definitions/#{definition_key}")
95
+ build_sub_spec_class(ref_schema, name)
96
+ end
97
+ end
98
+
99
+ # All available definition short names for this version.
100
+ #
101
+ # @return [Array<String>] sorted, deduplicated short names
102
+ def list_definitions
103
+ schemer.value.fetch("definitions", {}).keys
104
+ .map { |k| k.split(".").last }
105
+ .uniq.sort
106
+ end
107
+
74
108
  private
75
109
 
76
110
  # The JSONSchemer instance for this version's Swagger document.
@@ -203,7 +237,7 @@ module Kube
203
237
  end
204
238
 
205
239
  def self.schema_properties
206
- @schema_properties
240
+ @schema_properties || superclass.schema_properties
207
241
  end
208
242
 
209
243
  schema_instance.value["properties"].keys.then do |properties|
@@ -214,6 +248,62 @@ module Kube
214
248
  end
215
249
  end
216
250
 
251
+ # Resolve a short name like "Container" to its full definition key
252
+ # "io.k8s.api.core.v1.Container".
253
+ #
254
+ # Strategy:
255
+ # 1. Exact match on full key (for power users)
256
+ # 2. Short-name match on last segment
257
+ # 3. Prefer stable API versions (v1 > v1beta1 > v1alpha1)
258
+ def find_definition_key(name)
259
+ definitions = schemer.value.fetch("definitions", {})
260
+
261
+ # Exact full-key match
262
+ return name if definitions.key?(name)
263
+
264
+ # Short-name matches (last segment after final ".")
265
+ candidates = definitions.keys.select { |k| k.split(".").last == name }
266
+ return nil if candidates.empty?
267
+ return candidates.first if candidates.size == 1
268
+
269
+ # Disambiguation: prefer stable versions, then beta, then alpha
270
+ candidates.min_by { |k|
271
+ version_segment = k.split(".")[-2].to_s
272
+ case version_segment
273
+ when /\Av\d+\z/ then [0, version_segment]
274
+ when /beta/ then [1, version_segment]
275
+ when /alpha/ then [2, version_segment]
276
+ else [3, version_segment]
277
+ end
278
+ }
279
+ end
280
+
281
+ # Build a SubSpec subclass from a JSONSchemer instance and a
282
+ # human-readable definition name (for error messages).
283
+ def build_sub_spec_class(schema_instance, definition_name)
284
+ Class.new(::Kube::Schema::SubSpec) do
285
+ @schema = schema_instance
286
+ @definition_name = definition_name
287
+ @schema_properties = @schema.value.fetch("properties", {}).keys.map(&:to_sym)
288
+
289
+ def self.schema
290
+ @schema || superclass.schema
291
+ end
292
+
293
+ def self.definition_name
294
+ @definition_name || superclass.definition_name
295
+ end
296
+
297
+ def self.schema_properties
298
+ @schema_properties || superclass.schema_properties
299
+ end
300
+
301
+ schema_instance.value.fetch("properties", {}).keys.each do |prop|
302
+ define_method(prop.to_sym) { @data[prop.to_sym] }
303
+ end
304
+ end
305
+ end
306
+
217
307
  # Called by Kube::Schema.register and reset_custom_schemas! to
218
308
  # invalidate cached resource classes so new registrations take effect.
219
309
  def clear_resource_cache!
@@ -105,6 +105,8 @@ module Kube
105
105
 
106
106
  def deep_compact(obj)
107
107
  case obj
108
+ when Kube::Schema::SubSpec
109
+ deep_compact(obj.to_h)
108
110
  when Hash
109
111
  obj.each_with_object({}) do |(k, v), result|
110
112
  compacted = deep_compact(v)
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kube
4
+ module Schema
5
+ # A lightweight, schema-validated wrapper for non-resource Kubernetes
6
+ # definitions — things like Container, ContainerPort, Volume, Probe,
7
+ # etc. that live inside resource specs but have no apiVersion/kind.
8
+ #
9
+ # SubSpec validates against the OpenAPI JSON Schema definition and
10
+ # produces a plain Hash via #to_h, suitable for embedding directly
11
+ # inside Resource specs.
12
+ #
13
+ # container = Kube::Schema::SubSpec["Container"].new {
14
+ # name = "app"
15
+ # image = "nginx:1.27"
16
+ # ports = [{ containerPort: 80 }]
17
+ # }
18
+ #
19
+ # container.valid? # => true
20
+ # container.to_h # => { name: "app", image: "nginx:1.27", ... }
21
+ #
22
+ # SubSpec instances auto-coerce when placed inside a Resource —
23
+ # no explicit .to_h is needed:
24
+ #
25
+ # spec.template.spec.containers = [container]
26
+ #
27
+ class SubSpec
28
+
29
+ def initialize(hash = {}, &block)
30
+ deep_symbolize_keys(hash).then do |symbolized|
31
+ @data = {}
32
+
33
+ self.class.schema_properties.each do |property|
34
+ if symbolized.key?(property)
35
+ @data[property] = symbolized.delete(property)
36
+ end
37
+ end
38
+ end
39
+
40
+ if block_given?
41
+ @data.instance_exec(&block)
42
+ end
43
+ end
44
+
45
+ # Gets overridden by the factory in Kube::Schema::Instance
46
+ def self.schema
47
+ raise "Kube::Schema::SubSpec should NOT be instantiated directly"
48
+ end
49
+
50
+ def self.schema_properties
51
+ raise "Kube::Schema::SubSpec should NOT be instantiated directly"
52
+ end
53
+
54
+ def self.definition_name
55
+ raise "Kube::Schema::SubSpec should NOT be instantiated directly"
56
+ end
57
+
58
+ def valid?
59
+ if self.class.schema.nil?
60
+ true
61
+ else
62
+ self.class.schema.valid?(deep_stringify_keys(to_h))
63
+ end
64
+ end
65
+
66
+ # Like #valid? but raises Kube::ValidationError with details on failure.
67
+ def valid!
68
+ if self.class.schema.nil?
69
+ true
70
+ else
71
+ data = deep_stringify_keys(to_h)
72
+ errors = self.class.schema.validate(data).to_a
73
+
74
+ unless errors.empty?
75
+ raise Kube::ValidationError.new(errors,
76
+ kind: self.class.definition_name,
77
+ manifest: data
78
+ )
79
+ end
80
+
81
+ true
82
+ end
83
+ end
84
+
85
+ # Returns the sub-spec data as a plain Hash.
86
+ def to_h
87
+ data = deep_compact(@data)
88
+ data.reject { |_, v| v.is_a?(Hash) && v.empty? }
89
+ end
90
+
91
+ def ==(other)
92
+ other.is_a?(SubSpec) && to_h == other.to_h
93
+ end
94
+
95
+ # Look up a sub-spec definition by short name.
96
+ #
97
+ # Kube::Schema::SubSpec["Container"]
98
+ # Kube::Schema::SubSpec["ContainerPort"]
99
+ # Kube::Schema::SubSpec["Volume"]
100
+ #
101
+ class << self
102
+ def [](name)
103
+ version = Schema.schema_version || Schema::DEFAULT_VERSION
104
+ instance = Schema[version]
105
+ instance.sub_spec(name)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def deep_compact(obj)
112
+ case obj
113
+ when Hash
114
+ obj.each_with_object({}) do |(k, v), result|
115
+ compacted = deep_compact(v)
116
+ result[k] = compacted unless compacted.nil?
117
+ end
118
+ when Array
119
+ obj.map { |v| deep_compact(v) }
120
+ else
121
+ obj
122
+ end
123
+ end
124
+
125
+ def deep_stringify_keys(obj)
126
+ case obj
127
+ when Hash
128
+ obj.each_with_object({}) do |(k, v), result|
129
+ result[k.to_s] = deep_stringify_keys(v)
130
+ end
131
+ when Array
132
+ obj.map { |v| deep_stringify_keys(v) }
133
+ else
134
+ obj
135
+ end
136
+ end
137
+
138
+ def deep_symbolize_keys(obj)
139
+ case obj
140
+ when Hash
141
+ obj.each_with_object({}) do |(k, v), result|
142
+ result[k.to_sym] = deep_symbolize_keys(v)
143
+ end
144
+ when Array
145
+ obj.map { |v| deep_symbolize_keys(v) }
146
+ else
147
+ obj
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ if __FILE__ == $0
155
+ require "bundler/setup"
156
+ require "rspec/autorun"
157
+ require "kube/schema"
158
+
159
+ RSpec.describe Kube::Schema::SubSpec do
160
+ describe ".[]" do
161
+ it "returns a Class that subclasses SubSpec" do
162
+ klass = described_class["Container"]
163
+ expect(klass).to be_a(Class)
164
+ expect(klass).to be < described_class
165
+ end
166
+
167
+ it "caches sub-spec classes by name" do
168
+ a = described_class["Container"]
169
+ b = described_class["Container"]
170
+ expect(a).to be(b)
171
+ end
172
+
173
+ it "resolves ContainerPort" do
174
+ klass = described_class["ContainerPort"]
175
+ expect(klass).to be < described_class
176
+ end
177
+
178
+ it "resolves Probe" do
179
+ klass = described_class["Probe"]
180
+ expect(klass).to be < described_class
181
+ end
182
+
183
+ it "resolves Volume" do
184
+ klass = described_class["Volume"]
185
+ expect(klass).to be < described_class
186
+ end
187
+
188
+ it "resolves EnvVar" do
189
+ klass = described_class["EnvVar"]
190
+ expect(klass).to be < described_class
191
+ end
192
+
193
+ it "resolves ResourceRequirements" do
194
+ klass = described_class["ResourceRequirements"]
195
+ expect(klass).to be < described_class
196
+ end
197
+
198
+ it "resolves a full definition key" do
199
+ klass = described_class["io.k8s.api.core.v1.Container"]
200
+ expect(klass).to be < described_class
201
+ end
202
+
203
+ it "raises for an unknown definition" do
204
+ expect { described_class["ThisDoesNotExist999"] }.to raise_error(RuntimeError, /No definition found/)
205
+ end
206
+ end
207
+
208
+ describe ".definition_name" do
209
+ it "returns the short name used to look up the sub-spec" do
210
+ klass = described_class["Container"]
211
+ expect(klass.definition_name).to eq("Container")
212
+ end
213
+ end
214
+
215
+ describe ".schema" do
216
+ it "has a schema attached" do
217
+ klass = described_class["Container"]
218
+ expect(klass.schema).not_to be_nil
219
+ end
220
+ end
221
+
222
+ describe ".schema_properties" do
223
+ it "lists known properties as symbols" do
224
+ klass = described_class["Container"]
225
+ props = klass.schema_properties
226
+ expect(props).to include(:name, :image, :ports, :env, :resources)
227
+ end
228
+ end
229
+
230
+ describe "#initialize" do
231
+ let(:klass) { described_class["Container"] }
232
+
233
+ it "accepts a hash" do
234
+ sub = klass.new(name: "app", image: "nginx")
235
+ expect(sub.to_h).to include(name: "app", image: "nginx")
236
+ end
237
+
238
+ it "accepts string keys" do
239
+ sub = klass.new("name" => "app", "image" => "nginx")
240
+ expect(sub.to_h).to include(name: "app", image: "nginx")
241
+ end
242
+
243
+ it "creates an empty sub-spec when no arguments are given" do
244
+ sub = klass.new
245
+ expect(sub.to_h).to eq({})
246
+ end
247
+
248
+ it "accepts a block for DSL-style initialization" do
249
+ sub = klass.new {
250
+ self.name = "app"
251
+ self.image = "nginx:1.27"
252
+ }
253
+ expect(sub.to_h).to include(name: "app", image: "nginx:1.27")
254
+ end
255
+
256
+ it "supports nested DSL" do
257
+ sub = klass.new {
258
+ self.name = "app"
259
+ self.image = "nginx"
260
+ self.resources.requests = { cpu: "100m", memory: "128Mi" }
261
+ self.resources.limits = { cpu: "500m", memory: "256Mi" }
262
+ }
263
+ expect(sub.to_h[:resources][:requests]).to eq({ cpu: "100m", memory: "128Mi" })
264
+ end
265
+
266
+ it "ignores unknown properties" do
267
+ sub = klass.new(name: "app", bogus_field: "ignored")
268
+ expect(sub.to_h).to eq({ name: "app" })
269
+ end
270
+ end
271
+
272
+ describe "accessor methods" do
273
+ let(:klass) { described_class["Container"] }
274
+
275
+ it "defines reader methods for schema properties" do
276
+ sub = klass.new(name: "app", image: "nginx")
277
+ expect(sub.name).to eq("app")
278
+ expect(sub.image).to eq("nginx")
279
+ end
280
+
281
+ it "returns nil for unset properties" do
282
+ sub = klass.new(name: "app")
283
+ expect(sub.image).to be_nil
284
+ end
285
+ end
286
+
287
+ describe "#valid?" do
288
+ let(:klass) { described_class["Container"] }
289
+
290
+ it "returns true for valid data" do
291
+ sub = klass.new(name: "app", image: "nginx")
292
+ expect(sub.valid?).to be true
293
+ end
294
+
295
+ it "returns false for data violating the schema" do
296
+ sub = klass.new(name: "app", ports: "not_an_array")
297
+ expect(sub.valid?).to be false
298
+ end
299
+
300
+ it "returns false when required fields are missing" do
301
+ # Container requires 'name'
302
+ sub = klass.new(image: "nginx")
303
+ expect(sub.valid?).to be false
304
+ end
305
+ end
306
+
307
+ describe "#valid!" do
308
+ let(:klass) { described_class["Container"] }
309
+
310
+ it "returns true for valid data" do
311
+ sub = klass.new(name: "app")
312
+ expect(sub.valid!).to be true
313
+ end
314
+
315
+ it "raises ValidationError for invalid data" do
316
+ sub = klass.new(image: "nginx")
317
+ expect { sub.valid! }.to raise_error(Kube::ValidationError)
318
+ end
319
+
320
+ it "includes the definition name in the error" do
321
+ sub = klass.new(image: "nginx")
322
+ expect { sub.valid! }.to raise_error(Kube::ValidationError, /Container/)
323
+ end
324
+
325
+ it "shows which required keys are missing" do
326
+ sub = klass.new(image: "nginx")
327
+ expect { sub.valid! }.to raise_error(Kube::ValidationError) do |error|
328
+ expect(error.message).to include("name is required but missing")
329
+ end
330
+ end
331
+ end
332
+
333
+ describe "#to_h" do
334
+ let(:klass) { described_class["Container"] }
335
+
336
+ it "returns a plain Hash" do
337
+ sub = klass.new(name: "app", image: "nginx")
338
+ h = sub.to_h
339
+ expect(h).to be_a(Hash)
340
+ expect(h).to eq({ name: "app", image: "nginx" })
341
+ end
342
+
343
+ it "strips empty sub-hashes" do
344
+ sub = klass.new(name: "app")
345
+ expect(sub.to_h).to eq({ name: "app" })
346
+ end
347
+
348
+ it "does NOT include apiVersion or kind" do
349
+ sub = klass.new(name: "app")
350
+ expect(sub.to_h).not_to have_key(:apiVersion)
351
+ expect(sub.to_h).not_to have_key(:kind)
352
+ end
353
+
354
+ it "preserves nested structure" do
355
+ sub = klass.new {
356
+ self.name = "app"
357
+ self.image = "nginx"
358
+ self.ports = [{ containerPort: 80 }]
359
+ }
360
+ expect(sub.to_h[:ports]).to eq([{ containerPort: 80 }])
361
+ end
362
+ end
363
+
364
+ describe "#==" do
365
+ let(:klass) { described_class["Container"] }
366
+
367
+ it "considers two sub-specs equal when their data matches" do
368
+ a = klass.new(name: "app", image: "nginx")
369
+ b = klass.new(name: "app", image: "nginx")
370
+ expect(a).to eq(b)
371
+ end
372
+
373
+ it "considers two sub-specs unequal when their data differs" do
374
+ a = klass.new(name: "app", image: "nginx")
375
+ b = klass.new(name: "other", image: "nginx")
376
+ expect(a).not_to eq(b)
377
+ end
378
+
379
+ it "is not equal to a plain Hash" do
380
+ sub = klass.new(name: "app")
381
+ expect(sub).not_to eq({ name: "app" })
382
+ end
383
+ end
384
+
385
+ describe "auto-coercion in Resource" do
386
+ it "auto-coerces SubSpec instances inside Resource arrays" do
387
+ container = described_class["Container"].new {
388
+ self.name = "app"
389
+ self.image = "nginx:1.27"
390
+ self.ports = [{ containerPort: 80 }]
391
+ }
392
+
393
+ deploy = Kube::Schema["Deployment"].new {
394
+ metadata.name = "web"
395
+ spec.replicas = 1
396
+ spec.selector.matchLabels = { app: "web" }
397
+ spec.template.metadata.labels = { app: "web" }
398
+ spec.template.spec.containers = [container]
399
+ }
400
+
401
+ h = deploy.to_h
402
+ containers = h[:spec][:template][:spec][:containers]
403
+ expect(containers).to be_an(Array)
404
+ expect(containers.first).to be_a(Hash)
405
+ expect(containers.first[:name]).to eq("app")
406
+ expect(containers.first[:image]).to eq("nginx:1.27")
407
+ end
408
+
409
+ it "produces valid YAML with auto-coerced SubSpec" do
410
+ container = described_class["Container"].new {
411
+ self.name = "nginx"
412
+ self.image = "nginx:1.27"
413
+ self.ports = [{ containerPort: 80 }]
414
+ }
415
+
416
+ deploy = Kube::Schema["Deployment"].new {
417
+ metadata.name = "web"
418
+ spec.replicas = 1
419
+ spec.selector.matchLabels = { app: "web" }
420
+ spec.template.metadata.labels = { app: "web" }
421
+ spec.template.spec.containers = [container]
422
+ }
423
+
424
+ yaml = deploy.to_yaml
425
+ parsed = YAML.safe_load(yaml)
426
+ expect(parsed["spec"]["template"]["spec"]["containers"].first["name"]).to eq("nginx")
427
+ end
428
+
429
+ it "handles multiple SubSpec instances in an array" do
430
+ app = described_class["Container"].new(name: "app", image: "app:latest")
431
+ sidecar = described_class["Container"].new(name: "sidecar", image: "sidecar:latest")
432
+
433
+ deploy = Kube::Schema["Deployment"].new {
434
+ metadata.name = "web"
435
+ spec.replicas = 1
436
+ spec.selector.matchLabels = { app: "web" }
437
+ spec.template.metadata.labels = { app: "web" }
438
+ spec.template.spec.containers = [app, sidecar]
439
+ }
440
+
441
+ containers = deploy.to_h[:spec][:template][:spec][:containers]
442
+ expect(containers.size).to eq(2)
443
+ expect(containers.map { |c| c[:name] }).to eq(["app", "sidecar"])
444
+ end
445
+
446
+ it "works mixed with plain hashes" do
447
+ container = described_class["Container"].new(name: "typed", image: "typed:latest")
448
+
449
+ deploy = Kube::Schema["Deployment"].new {
450
+ metadata.name = "web"
451
+ spec.replicas = 1
452
+ spec.selector.matchLabels = { app: "web" }
453
+ spec.template.metadata.labels = { app: "web" }
454
+ spec.template.spec.containers = [
455
+ container,
456
+ { name: "plain", image: "plain:latest" }
457
+ ]
458
+ }
459
+
460
+ containers = deploy.to_h[:spec][:template][:spec][:containers]
461
+ expect(containers.map { |c| c[:name] }).to eq(["typed", "plain"])
462
+ end
463
+ end
464
+
465
+ describe "disambiguation" do
466
+ it "prefers stable API versions over beta/alpha" do
467
+ # MatchCondition exists in v1, v1alpha1, and v1beta1
468
+ klass = described_class["MatchCondition"]
469
+ expect(klass).to be < described_class
470
+ expect(klass.schema).not_to be_nil
471
+ end
472
+ end
473
+
474
+ describe "Instance#list_definitions" do
475
+ it "returns a sorted array of short definition names" do
476
+ instance = Kube::Schema::Instance.new("1.34")
477
+ defs = instance.list_definitions
478
+ expect(defs).to be_an(Array)
479
+ expect(defs).not_to be_empty
480
+ expect(defs).to include("Container", "ContainerPort", "Probe", "Volume")
481
+ expect(defs).to eq(defs.sort)
482
+ end
483
+ end
484
+ end
485
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Schema
5
- VERSION = "1.3.10"
5
+ VERSION = "1.4.1"
6
6
  end
7
7
  end
data/lib/kube/schema.rb CHANGED
@@ -4,6 +4,7 @@ require_relative 'monkey_patches'
4
4
  require_relative 'errors'
5
5
  require_relative 'schema/version'
6
6
  require_relative 'schema/resource'
7
+ require_relative 'schema/sub_spec'
7
8
  require_relative 'schema/instance'
8
9
  require_relative 'schema/manifest'
9
10
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kube_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.10
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan K
@@ -113,6 +113,7 @@ files:
113
113
  - examples/basic.rb
114
114
  - examples/custom_crds.rb
115
115
  - examples/manifest.rb
116
+ - examples/sub_specs.rb
116
117
  - examples/vcluster.rb
117
118
  - flake.lock
118
119
  - flake.nix
@@ -123,6 +124,7 @@ files:
123
124
  - lib/kube/schema/instance.rb
124
125
  - lib/kube/schema/manifest.rb
125
126
  - lib/kube/schema/resource.rb
127
+ - lib/kube/schema/sub_spec.rb
126
128
  - lib/kube/schema/version.rb
127
129
  - lib/kube/schema/version.rb.erb
128
130
  - schemas/crd-definitions.json