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.
@@ -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.9"
5
+ VERSION = "1.4.0"
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