kube_schema 1.3.9 → 1.4.0

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: 6aa0c152a33c1414287852fa61f79812af549964f17bb95546b6a27f639927f8
4
- data.tar.gz: fe3a7c986782b8cb49d7f03c23ac749158ec86f4a7a75d2a138cdd63473f332c
3
+ metadata.gz: f145d4e219013c8c1f0091ce99d7a2a10d1700201e77c6ed2d3d26db140206b0
4
+ data.tar.gz: 0114343af9ef19eb9f7496ed0506859126cef3912bb593c855c36565550dd504
5
5
  SHA512:
6
- metadata.gz: c87f68db595b46071d3603283804ead4e0cf943ee4a7873743fbeb6ddb383f331bc351fb8bc30bb2c3bf77f6db00ab20c6dd663af6bc9f2ac4e658d605861146
7
- data.tar.gz: c1b5704684f5b7070103c5dc4c2187bc85d440db73be64f157086526dace4a553bc707f9e6102dab065876085f9c93850c5aebad6efcbbb9520a9ee0157d927e
6
+ metadata.gz: cd5ee4e5f81546204cc2ff1040131a3cb09f4d512a4230adcaab86f7a2db0c65d0ec3034fc6613461f8de2eb549ea7c2e297da6e3de5ddc107f23629afe93727
7
+ data.tar.gz: cf4e3bf4b4a913ccc5108fe42c274c350d7d09db67fdc195028f6f0ddd513d82fa0799674d453cdfd7f3e7c086739c4150175cd7c62b5b7ab5ffca90e8b32b3f
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kube_schema (1.3.9)
4
+ kube_schema (1.4.0)
5
5
  json_schemer (~> 2.5.0)
6
6
  rubyshell (~> 1.5.0)
7
7
 
@@ -9,3 +9,5 @@ git ls-tree --name-only schemas2 data/k8s.io/ | while read -r path; do
9
9
  git show "schemas2:$path" > "schemas/$(basename "$path")"
10
10
  done
11
11
 
12
+ bin/create-kubevirt-x-values
13
+
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Rewrites schemas/kubevirt-definitions.json in-place to:
4
+ # 1. Add x-kubernetes-group-version-kind annotations
5
+ # 2. Fix $ref pointers from k8s.io.* to io.k8s.* prefix
6
+ #
7
+ set -euo pipefail
8
+
9
+ DEFS_FILE="schemas/kubevirt-definitions.json"
10
+
11
+ echo "Rewriting $DEFS_FILE..."
12
+
13
+ jq '
14
+ # GVK map: definition key -> [{ group, version, kind }]
15
+ {
16
+ "v1.KubeVirt": [{ group: "kubevirt.io", version: "v1", kind: "KubeVirt" }],
17
+ "v1.VirtualMachine": [{ group: "kubevirt.io", version: "v1", kind: "VirtualMachine" }],
18
+ "v1.VirtualMachineExport": [{ group: "export.kubevirt.io", version: "v1", kind: "VirtualMachineExport" }],
19
+ "v1.VirtualMachineInstance": [{ group: "kubevirt.io", version: "v1", kind: "VirtualMachineInstance" }],
20
+ "v1.VirtualMachineInstanceMigration": [{ group: "kubevirt.io", version: "v1", kind: "VirtualMachineInstanceMigration" }],
21
+ "v1.VirtualMachineInstancePreset": [{ group: "kubevirt.io", version: "v1", kind: "VirtualMachineInstancePreset" }],
22
+ "v1.VirtualMachineInstanceReplicaSet": [{ group: "kubevirt.io", version: "v1", kind: "VirtualMachineInstanceReplicaSet" }],
23
+ "v1alpha1.MigrationPolicy": [{ group: "migrations.kubevirt.io", version: "v1alpha1", kind: "MigrationPolicy" }],
24
+ "v1alpha1.VirtualMachineBackup": [{ group: "backup.kubevirt.io", version: "v1alpha1", kind: "VirtualMachineBackup" }],
25
+ "v1beta1.VirtualMachineClone": [{ group: "clone.kubevirt.io", version: "v1beta1", kind: "VirtualMachineClone" }],
26
+ "v1beta1.VirtualMachineClusterInstancetype":[{ group: "instancetype.kubevirt.io", version: "v1beta1", kind: "VirtualMachineClusterInstancetype" }],
27
+ "v1beta1.VirtualMachineClusterPreference": [{ group: "instancetype.kubevirt.io", version: "v1beta1", kind: "VirtualMachineClusterPreference" }],
28
+ "v1beta1.VirtualMachineInstancetype": [{ group: "instancetype.kubevirt.io", version: "v1beta1", kind: "VirtualMachineInstancetype" }],
29
+ "v1beta1.VirtualMachinePool": [{ group: "pool.kubevirt.io", version: "v1beta1", kind: "VirtualMachinePool" }],
30
+ "v1beta1.VirtualMachinePreference": [{ group: "instancetype.kubevirt.io", version: "v1beta1", kind: "VirtualMachinePreference" }],
31
+ "v1beta1.VirtualMachineRestore": [{ group: "snapshot.kubevirt.io", version: "v1beta1", kind: "VirtualMachineRestore" }],
32
+ "v1beta1.VirtualMachineSnapshot": [{ group: "snapshot.kubevirt.io", version: "v1beta1", kind: "VirtualMachineSnapshot" }],
33
+ "v1beta1.VirtualMachineSnapshotContent": [{ group: "snapshot.kubevirt.io", version: "v1beta1", kind: "VirtualMachineSnapshotContent" }]
34
+ } as $gvk_map |
35
+
36
+ # Inject x-kubernetes-group-version-kind annotations
37
+ with_entries(
38
+ if $gvk_map[.key] then
39
+ .value["x-kubernetes-group-version-kind"] = $gvk_map[.key]
40
+ else . end
41
+ ) |
42
+
43
+ # Fix $ref pointers: kubevirt uses k8s.io.* but base schema uses io.k8s.*
44
+ walk(
45
+ if type == "string" and startswith("#/definitions/k8s.io.")
46
+ then sub("^#/definitions/k8s\\.io\\."; "#/definitions/io.k8s.")
47
+ else . end
48
+ )
49
+ ' "$DEFS_FILE" > "${DEFS_FILE}.tmp" && mv "${DEFS_FILE}.tmp" "$DEFS_FILE"
50
+
51
+ ANNOTATED=$(jq '[.[] | select(has("x-kubernetes-group-version-kind"))] | length' "$DEFS_FILE")
52
+ TOTAL=$(jq 'length' "$DEFS_FILE")
53
+ STALE_REFS=$(jq -r '[.. | strings | select(startswith("#/definitions/k8s.io."))] | length' "$DEFS_FILE")
54
+ echo "Done. $ANNOTATED/$TOTAL definitions annotated. $STALE_REFS stale k8s.io refs remaining."
@@ -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.
@@ -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
295
+ end
296
+
297
+ def self.schema_properties
298
+ @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!
@@ -345,6 +435,22 @@ if __FILE__ == $0
345
435
  expect(resource.to_h[:kind]).to eq("VirtualMachine")
346
436
  expect(resource.to_h[:metadata][:name]).to eq("test-vm")
347
437
  end
438
+
439
+ it "validates a VirtualMachine without $ref resolution errors" do
440
+ resource = instance["VirtualMachine"].new {
441
+ metadata.name = "test-vm"
442
+ spec.runStrategy = "Always"
443
+ spec.template.metadata.labels = { "kubevirt.io/vm": "test-vm" }
444
+ spec.template.spec.domain.cpu = { cores: 1 }
445
+ spec.template.spec.domain.devices = {}
446
+ spec.template.spec.domain.memory = { guest: "1Gi" }
447
+ spec.template.spec.domain.resources = { requests: { memory: "1Gi" } }
448
+ spec.template.spec.volumes = [
449
+ { name: "rootdisk", containerDisk: { image: "registry.example.com/vm:latest" } }
450
+ ]
451
+ }
452
+ expect { resource.to_yaml }.not_to raise_error
453
+ end
348
454
  end
349
455
 
350
456
  describe "class-level schemer cache" do
@@ -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)