kube_schema 1.3.10 → 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 +4 -4
- data/Gemfile.lock +1 -1
- data/examples/sub_specs.rb +412 -0
- data/lib/kube/schema/instance.rb +90 -0
- data/lib/kube/schema/resource.rb +2 -0
- data/lib/kube/schema/sub_spec.rb +485 -0
- data/lib/kube/schema/version.rb +1 -1
- data/lib/kube/schema.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f145d4e219013c8c1f0091ce99d7a2a10d1700201e77c6ed2d3d26db140206b0
|
|
4
|
+
data.tar.gz: 0114343af9ef19eb9f7496ed0506859126cef3912bb593c855c36565550dd504
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd5ee4e5f81546204cc2ff1040131a3cb09f4d512a4230adcaab86f7a2db0c65d0ec3034fc6613461f8de2eb549ea7c2e297da6e3de5ddc107f23629afe93727
|
|
7
|
+
data.tar.gz: cf4e3bf4b4a913ccc5108fe42c274c350d7d09db67fdc195028f6f0ddd513d82fa0799674d453cdfd7f3e7c086739c4150175cd7c62b5b7ab5ffca90e8b32b3f
|
data/Gemfile.lock
CHANGED
|
@@ -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
|
data/lib/kube/schema/instance.rb
CHANGED
|
@@ -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!
|
data/lib/kube/schema/resource.rb
CHANGED
|
@@ -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
|
data/lib/kube/schema/version.rb
CHANGED
data/lib/kube/schema.rb
CHANGED
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.
|
|
4
|
+
version: 1.4.0
|
|
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
|