kube_schema 1.3.4 → 1.3.6

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.
@@ -145,3 +145,409 @@ module Kube
145
145
  end
146
146
  end
147
147
  end
148
+
149
+ if __FILE__ == $0
150
+ require "bundler/setup"
151
+ require "rspec/autorun"
152
+ require "kube/schema"
153
+
154
+ RSpec.describe Kube::Schema::Resource do
155
+ describe ".defaults" do
156
+ it "returns apiVersion and kind for a schema-bearing subclass" do
157
+ klass = Kube::Schema["Deployment"]
158
+ expect(klass.defaults).to eq({ "apiVersion" => "apps/v1", "kind" => "Deployment" })
159
+ end
160
+
161
+ it "returns correct apiVersion for core resources (no group)" do
162
+ klass = Kube::Schema["Pod"]
163
+ expect(klass.defaults).to eq({ "apiVersion" => "v1", "kind" => "Pod" })
164
+ end
165
+
166
+ it "returns correct apiVersion for grouped resources" do
167
+ klass = Kube::Schema["NetworkPolicy"]
168
+ expect(klass.defaults).to eq({ "apiVersion" => "networking.k8s.io/v1", "kind" => "NetworkPolicy" })
169
+ end
170
+ end
171
+
172
+ describe "#initialize" do
173
+ it "accepts a hash" do
174
+ klass = Kube::Schema["Deployment"]
175
+ resource = klass.new("metadata" => { "name" => "my-deploy" })
176
+ expect(resource.to_h).to include(kind: "Deployment")
177
+ expect(resource.to_h[:metadata][:name]).to eq("my-deploy")
178
+ end
179
+
180
+ it "creates an empty resource when no arguments are given" do
181
+ klass = Kube::Schema["Deployment"]
182
+ resource = klass.new
183
+ expect(resource.to_h).to include(apiVersion: "apps/v1", kind: "Deployment")
184
+ end
185
+
186
+ it "accepts a block for DSL-style initialization" do
187
+ klass = Kube::Schema["Deployment"]
188
+ resource = klass.new {
189
+ metadata.name = "test"
190
+ }
191
+ expect(resource.to_h).to include(kind: "Deployment")
192
+ expect(resource.to_h[:metadata][:name]).to eq("test")
193
+ end
194
+ end
195
+
196
+ describe "#to_h" do
197
+ context "with a schema-bearing subclass" do
198
+ let(:klass) { Kube::Schema["Deployment"] }
199
+
200
+ it "automatically includes apiVersion and kind from defaults" do
201
+ resource = klass.new {
202
+ metadata.name = "test"
203
+ }
204
+ expect(resource.to_h[:apiVersion]).to eq("apps/v1")
205
+ expect(resource.to_h[:kind]).to eq("Deployment")
206
+ expect(resource.to_h[:metadata][:name]).to eq("test")
207
+ end
208
+
209
+ it "cannot override apiVersion or kind -- they are authoritative" do
210
+ resource = klass.new {
211
+ self.apiVersion = "apps/v1beta1"
212
+ self.kind = "NotADeployment"
213
+ }
214
+ expect(resource.to_h[:apiVersion]).to eq("apps/v1")
215
+ expect(resource.to_h[:kind]).to eq("Deployment")
216
+ end
217
+ end
218
+ end
219
+
220
+ describe "#==" do
221
+ it "considers two resources equal when their data matches" do
222
+ a = Kube::Schema["Pod"].new
223
+ b = Kube::Schema["Pod"].new
224
+ expect(a).to eq(b)
225
+ end
226
+
227
+ it "considers two resources unequal when their data differs" do
228
+ a = Kube::Schema["Pod"].new
229
+ b = Kube::Schema["Service"].new
230
+ expect(a).not_to eq(b)
231
+ end
232
+
233
+ it "is not equal to non-Resource objects" do
234
+ resource = Kube::Schema["Pod"].new
235
+ expect(resource).not_to eq({ "kind" => "Pod" })
236
+ end
237
+ end
238
+
239
+ describe "#valid?" do
240
+ context "with a schema-bearing subclass" do
241
+ let(:klass) { Kube::Schema["Deployment"] }
242
+
243
+ it "returns true for valid data (apiVersion/kind come from defaults)" do
244
+ resource = klass.new
245
+ expect(resource.valid?).to be true
246
+ end
247
+
248
+ it "returns false for data violating the schema" do
249
+ resource = klass.new {
250
+ spec.replicas = "not_a_number"
251
+ }
252
+ expect(resource.valid?).to be false
253
+ end
254
+ end
255
+ end
256
+
257
+ describe "#valid!" do
258
+ context "with a schema-bearing subclass" do
259
+ let(:klass) { Kube::Schema["Deployment"] }
260
+
261
+ it "returns true for valid data" do
262
+ resource = klass.new
263
+ expect(resource.valid!).to be true
264
+ end
265
+
266
+ it "raises ValidationError for data violating the schema" do
267
+ resource = klass.new {
268
+ spec.replicas = "not_a_number"
269
+ }
270
+ expect { resource.valid! }.to raise_error(Kube::ValidationError)
271
+ end
272
+
273
+ it "includes error details in the exception" do
274
+ resource = klass.new {
275
+ spec.replicas = "not_a_number"
276
+ }
277
+ expect { resource.valid! }.to raise_error(Kube::ValidationError, /Schema validation failed/)
278
+ end
279
+
280
+ it "shows the exact key path and value for type errors" do
281
+ resource = klass.new {
282
+ metadata.name = "web"
283
+ spec.replicas = "not_a_number"
284
+ }
285
+ expect { resource.valid! }.to raise_error(Kube::ValidationError) do |error|
286
+ expect(error.message).to include('spec.replicas = "not_a_number" — expected integer, got String')
287
+ end
288
+ end
289
+
290
+ it "shows which required keys are missing" do
291
+ resource = klass.new {
292
+ metadata.name = "example"
293
+ spec.replicas = 1
294
+ spec.template.spec.containers = [{ name: "app", image: "ruby:latest" }]
295
+ }
296
+ expect { resource.valid! }.to raise_error(Kube::ValidationError) do |error|
297
+ expect(error.message).to include("spec.selector is required but missing")
298
+ end
299
+ end
300
+
301
+ it "includes the resource kind in the error header" do
302
+ resource = klass.new {
303
+ metadata.name = "web"
304
+ spec.replicas = "bad"
305
+ }
306
+ expect { resource.valid! }.to raise_error(Kube::ValidationError) do |error|
307
+ expect(error.message).to include("Schema validation failed for Deployment")
308
+ end
309
+ end
310
+
311
+ it "includes the resource name in the error header when available" do
312
+ resource = klass.new {
313
+ metadata.name = "my-app"
314
+ spec.replicas = "bad"
315
+ }
316
+ expect { resource.valid! }.to raise_error(Kube::ValidationError) do |error|
317
+ expect(error.message).to include('Deployment "my-app"')
318
+ end
319
+ end
320
+
321
+ it "omits the resource name when metadata.name is not set" do
322
+ resource = klass.new {
323
+ spec.replicas = "bad"
324
+ }
325
+ expect { resource.valid! }.to raise_error(Kube::ValidationError) do |error|
326
+ header = error.message.lines.find { |l| l.include?("Schema validation failed") }
327
+ expect(header).to include("Schema validation failed for Deployment")
328
+ expect(header).not_to match(/Deployment\s+"/) # no quoted name after kind
329
+ end
330
+ end
331
+
332
+ it "exposes the raw errors array" do
333
+ resource = klass.new {
334
+ spec.replicas = "not_a_number"
335
+ }
336
+ begin
337
+ resource.valid!
338
+ rescue Kube::ValidationError => e
339
+ expect(e.errors).to be_an(Array)
340
+ expect(e.errors).not_to be_empty
341
+ end
342
+ end
343
+ end
344
+ end
345
+
346
+ describe "#to_yaml" do
347
+ it "returns clean Kubernetes YAML" do
348
+ resource = Kube::Schema["Pod"].new
349
+ yaml = resource.to_yaml
350
+
351
+ expect(yaml).to include("kind: Pod")
352
+ expect(yaml).to include("apiVersion: v1")
353
+ expect(yaml).not_to include("BlackHoleStruct")
354
+ expect(yaml).not_to include("!ruby/object")
355
+ end
356
+
357
+ it "uses string keys, not symbol keys" do
358
+ resource = Kube::Schema["Pod"].new
359
+ yaml = resource.to_yaml
360
+
361
+ expect(yaml).not_to match(/:\w+:/)
362
+ expect(yaml).to include("kind: Pod")
363
+ end
364
+
365
+ it "produces parseable YAML that round-trips" do
366
+ resource = Kube::Schema["Pod"].new
367
+ parsed = YAML.safe_load(resource.to_yaml)
368
+
369
+ expect(parsed).to be_a(Hash)
370
+ expect(parsed["kind"]).to eq("Pod")
371
+ expect(parsed["apiVersion"]).to eq("v1")
372
+ end
373
+
374
+ it "raises ValidationError when the resource is invalid" do
375
+ klass = Kube::Schema["Deployment"]
376
+ resource = klass.new {
377
+ spec.replicas = "not_a_number"
378
+ }
379
+ expect { resource.to_yaml }.to raise_error(Kube::ValidationError)
380
+ end
381
+
382
+ it "raises ValidationError for an incomplete Deployment missing selector" do
383
+ klass = Kube::Schema["Deployment"]
384
+ resource = klass.new {
385
+ metadata.namespace = "example"
386
+ metadata.name = "example-deployment"
387
+ spec.replicas = 1
388
+ spec.template.spec.containers = [
389
+ { name: "app", image: "ruby:latest" }
390
+ ]
391
+ }
392
+ expect { resource.to_yaml }.to raise_error(Kube::ValidationError)
393
+ end
394
+
395
+ context "with a full Deployment (no manual apiVersion/kind)" do
396
+ let(:deployment) do
397
+ Kube::Schema["Deployment"].new {
398
+ metadata.name = "nginx-deployment"
399
+ metadata.namespace = "shopping-cart"
400
+ metadata.labels = { app: "nginx" }
401
+ spec.replicas = 3
402
+ spec.selector.matchLabels = { app: "nginx" }
403
+ spec.template.metadata.labels = { app: "nginx" }
404
+ spec.template.spec.containers = [
405
+ { name: "nginx", image: "nginx:1.19.5", ports: [{ containerPort: 80 }] }
406
+ ]
407
+ }
408
+ end
409
+
410
+ it "produces valid Kubernetes Deployment YAML" do
411
+ yaml = deployment.to_yaml
412
+ parsed = YAML.safe_load(yaml)
413
+
414
+ expect(parsed).to be_a(Hash)
415
+ expect(parsed["apiVersion"]).to eq("apps/v1")
416
+ expect(parsed["kind"]).to eq("Deployment")
417
+ expect(parsed["metadata"]["name"]).to eq("nginx-deployment")
418
+ expect(parsed["metadata"]["namespace"]).to eq("shopping-cart")
419
+ expect(parsed["metadata"]["labels"]).to eq({ "app" => "nginx" })
420
+ expect(parsed["spec"]["replicas"]).to eq(3)
421
+ expect(parsed["spec"]["selector"]["matchLabels"]).to eq({ "app" => "nginx" })
422
+ expect(parsed["spec"]["template"]["metadata"]["labels"]).to eq({ "app" => "nginx" })
423
+
424
+ containers = parsed["spec"]["template"]["spec"]["containers"]
425
+ expect(containers).to be_an(Array)
426
+ expect(containers.length).to eq(1)
427
+ expect(containers[0]["name"]).to eq("nginx")
428
+ expect(containers[0]["image"]).to eq("nginx:1.19.5")
429
+ expect(containers[0]["ports"]).to eq([{ "containerPort" => 80 }])
430
+ end
431
+
432
+ it "does not contain Ruby object serialization artifacts" do
433
+ yaml = deployment.to_yaml
434
+
435
+ expect(yaml).not_to include("!ruby/object")
436
+ expect(yaml).not_to include("BlackHoleStruct")
437
+ expect(yaml).not_to include("table:")
438
+ end
439
+
440
+ it "looks like real kubectl YAML output" do
441
+ yaml = deployment.to_yaml
442
+
443
+ expect(yaml).to include("apiVersion: apps/v1")
444
+ expect(yaml).to include("kind: Deployment")
445
+ expect(yaml).to include("name: nginx-deployment")
446
+ expect(yaml).to include("namespace: shopping-cart")
447
+ expect(yaml).to include("replicas: 3")
448
+ expect(yaml).to include("image: nginx:1.19.5")
449
+ expect(yaml).to include("containerPort: 80")
450
+ end
451
+
452
+ it "exactly matches real Kubernetes Deployment YAML" do
453
+ expected_yaml = <<~YAML
454
+ ---
455
+ apiVersion: apps/v1
456
+ kind: Deployment
457
+ metadata:
458
+ name: nginx-deployment
459
+ namespace: shopping-cart
460
+ labels:
461
+ app: nginx
462
+ spec:
463
+ replicas: 3
464
+ selector:
465
+ matchLabels:
466
+ app: nginx
467
+ template:
468
+ metadata:
469
+ labels:
470
+ app: nginx
471
+ spec:
472
+ containers:
473
+ - name: nginx
474
+ image: nginx:1.19.5
475
+ ports:
476
+ - containerPort: 80
477
+ YAML
478
+
479
+ expect(deployment.to_yaml).to eq(expected_yaml)
480
+ end
481
+
482
+ it "round-trips through YAML.safe_load" do
483
+ parsed = YAML.safe_load(deployment.to_yaml)
484
+ expect(parsed["apiVersion"]).to eq("apps/v1")
485
+ expect(parsed["kind"]).to eq("Deployment")
486
+ end
487
+ end
488
+ end
489
+
490
+ describe "Deployment schema validation against real Kubernetes YAML" do
491
+ let(:klass) { Kube::Schema["Deployment"] }
492
+
493
+ let(:incomplete_deployment) do
494
+ klass.new {
495
+ metadata.namespace = "example"
496
+ metadata.name = "example-deployment"
497
+ spec.replicas = 1
498
+ spec.template.spec.containers = [
499
+ { name: "app", image: "ruby:latest" }
500
+ ]
501
+ }
502
+ end
503
+
504
+ it "rejects an incomplete Deployment missing selector" do
505
+ expect(incomplete_deployment.valid?).to be false
506
+ end
507
+
508
+ it "reports specific validation errors for incomplete Deployment" do
509
+ expect { incomplete_deployment.valid! }.to raise_error(Kube::ValidationError) do |error|
510
+ expect(error.message).to include("spec.selector is required but missing")
511
+ end
512
+ end
513
+
514
+ it "refuses to serialize an incomplete Deployment to YAML" do
515
+ expect { incomplete_deployment.to_yaml }.to raise_error(Kube::ValidationError)
516
+ end
517
+
518
+ it "has apiVersion and kind from defaults even when incomplete" do
519
+ h = incomplete_deployment.to_h
520
+ expect(h[:apiVersion]).to eq("apps/v1")
521
+ expect(h[:kind]).to eq("Deployment")
522
+ end
523
+ end
524
+
525
+ describe "instantiation via Instance lookup" do
526
+ let(:klass) { Kube::Schema["Deployment"] }
527
+
528
+ it "returns a Resource instance from .new" do
529
+ resource = klass.new
530
+ expect(resource).to be_a(described_class)
531
+ end
532
+
533
+ it "supports block-based initialization" do
534
+ resource = klass.new {
535
+ metadata.name = "web"
536
+ metadata.namespace = "prod"
537
+ }
538
+ expect(resource.to_h[:metadata][:name]).to eq("web")
539
+ expect(resource.to_h[:metadata][:namespace]).to eq("prod")
540
+ end
541
+
542
+ it "has a schema attached to the class" do
543
+ expect(klass.schema).not_to be_nil
544
+ end
545
+
546
+ it "has defaults attached to the class" do
547
+ expect(klass.defaults).not_to be_nil
548
+ expect(klass.defaults["apiVersion"]).to eq("apps/v1")
549
+ expect(klass.defaults["kind"]).to eq("Deployment")
550
+ end
551
+ end
552
+ end
553
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Schema
5
- VERSION = "1.3.4"
5
+ VERSION = "1.3.6"
6
6
  end
7
7
  end
data/lib/kube/schema.rb CHANGED
@@ -208,3 +208,152 @@ class BlackHoleStruct
208
208
  end
209
209
  end
210
210
  end
211
+
212
+ if __FILE__ == $0
213
+ require "bundler/setup"
214
+ require "rspec/autorun"
215
+
216
+ RSpec.describe Kube::Schema do
217
+ describe "::VERSION" do
218
+ it "is defined" do
219
+ expect(Kube::Schema::VERSION).not_to be_nil
220
+ end
221
+
222
+ it "is a valid semver string" do
223
+ expect(Kube::Schema::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
224
+ end
225
+ end
226
+
227
+ describe "::DEFAULT_VERSION" do
228
+ it "is a version present in schema_versions" do
229
+ expect(Kube::Schema.schema_versions).to include(Kube::Schema::DEFAULT_VERSION)
230
+ end
231
+ end
232
+
233
+ describe ".schema_versions" do
234
+ it "returns an array of version strings" do
235
+ versions = Kube::Schema.schema_versions
236
+ expect(versions).to be_an(Array)
237
+ expect(versions).not_to be_empty
238
+ expect(versions).to all(match(/\A\d+\.\d+/))
239
+ end
240
+
241
+ it "is sorted by Gem::Version" do
242
+ versions = Kube::Schema.schema_versions
243
+ sorted = versions.sort_by { |v| Gem::Version.new(v) }
244
+ expect(versions).to eq(sorted)
245
+ end
246
+
247
+ it "does not include a leading 'v' prefix" do
248
+ expect(Kube::Schema.schema_versions).to all(satisfy { |v| !v.start_with?("v") })
249
+ end
250
+ end
251
+
252
+ describe ".latest_version" do
253
+ it "returns the last element of schema_versions" do
254
+ expect(Kube::Schema.latest_version).to eq(Kube::Schema.schema_versions.last)
255
+ end
256
+ end
257
+
258
+ describe ".[]" do
259
+ context "with a version string" do
260
+ it "returns an Instance for a known version" do
261
+ instance = Kube::Schema["1.34"]
262
+ expect(instance).to be_a(Kube::Schema::Instance)
263
+ expect(instance.version).to eq("1.34")
264
+ end
265
+
266
+ it "caches Instance objects by version" do
267
+ a = Kube::Schema["1.34"]
268
+ b = Kube::Schema["1.34"]
269
+ expect(a).to be(b)
270
+ end
271
+
272
+ it "raises UnknownVersionError for an invalid version" do
273
+ expect { Kube::Schema["0.0.1"] }.to raise_error(Kube::UnknownVersionError)
274
+ end
275
+ end
276
+
277
+ context "with a resource name" do
278
+ it "returns a Class that subclasses Resource" do
279
+ klass = Kube::Schema["Deployment"]
280
+ expect(klass).to be_a(Class)
281
+ expect(klass).to be < Kube::Schema::Resource
282
+ end
283
+ end
284
+
285
+ context "with a 'v'-prefixed version string" do
286
+ it "raises IncorrectVersionFormat" do
287
+ expect { Kube::Schema["v1.34"] }.to raise_error(
288
+ Kube::IncorrectVersionFormat,
289
+ /Don't preface the version with a "v"/
290
+ )
291
+ end
292
+
293
+ it "suggests the correct format in the error message" do
294
+ expect { Kube::Schema["v1.34"] }.to raise_error(
295
+ Kube::IncorrectVersionFormat,
296
+ /Use Kube::Schema\["1\.34"\] instead/
297
+ )
298
+ end
299
+ end
300
+ end
301
+
302
+ describe ".parse" do
303
+ it "returns a typed Resource for a known kind" do
304
+ resource = Kube::Schema.parse("kind" => "Deployment", "apiVersion" => "apps/v1")
305
+ expect(resource).to be_a(Kube::Schema::Resource)
306
+ expect(resource.kind).to eq("Deployment")
307
+ expect(resource.apiVersion).to eq("apps/v1")
308
+ end
309
+
310
+ it "works with symbol keys" do
311
+ resource = Kube::Schema.parse(kind: "Pod", apiVersion: "v1", metadata: { name: "web" })
312
+ expect(resource).to be_a(Kube::Schema::Resource)
313
+ expect(resource.kind).to eq("Pod")
314
+ expect(resource.metadata.name).to eq("web")
315
+ end
316
+
317
+ it "returns a class backed by the correct schema" do
318
+ resource = Kube::Schema.parse("kind" => "Service", "apiVersion" => "v1")
319
+ expect(resource.class.schema).not_to be_nil
320
+ expect(resource.class.defaults).to eq({ "apiVersion" => "v1", "kind" => "Service" })
321
+ end
322
+
323
+ it "round-trips through to_h" do
324
+ original = Kube::Schema["Deployment"].new {
325
+ metadata.name = "web"
326
+ spec.replicas = 3
327
+ spec.selector.matchLabels = { app: "web" }
328
+ spec.template.metadata.labels = { app: "web" }
329
+ spec.template.spec.containers = [{ name: "web", image: "nginx" }]
330
+ }
331
+ parsed = Kube::Schema.parse(original.to_h)
332
+ expect(parsed.kind).to eq("Deployment")
333
+ expect(parsed.metadata.name).to eq("web")
334
+ end
335
+
336
+ it "raises ArgumentError for a non-Hash" do
337
+ expect { Kube::Schema.parse("not a hash") }.to raise_error(ArgumentError, /Expected a Hash/)
338
+ end
339
+
340
+ it "raises ArgumentError when kind is missing" do
341
+ expect { Kube::Schema.parse("apiVersion" => "v1") }.to raise_error(ArgumentError, /kind/)
342
+ end
343
+
344
+ it "raises RuntimeError for an unknown kind" do
345
+ expect { Kube::Schema.parse("kind" => "BogusKind", "apiVersion" => "v1") }.to raise_error(RuntimeError)
346
+ end
347
+ end
348
+
349
+ describe ".has_version?" do
350
+ it "returns true for a known version" do
351
+ expect(Kube::Schema.has_version?(Kube::Schema::DEFAULT_VERSION)).to be true
352
+ end
353
+
354
+ it "returns false for an unknown version" do
355
+ expect(Kube::Schema.has_version?("0.0.1")).to be false
356
+ end
357
+ end
358
+ end
359
+ end