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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 193f973be41e7f42709ecf8d2407d47d7200ab399a441563b34342149f759956
4
- data.tar.gz: 3a931d60cf56e1d03ed9a25eee098978a1e6f647113dc22f05f1858d0d03e045
3
+ metadata.gz: 40d05fe2759f67a63efc0f207fe42c311893fc35c5f40d3a8aac02a8b86c1ec7
4
+ data.tar.gz: de1aef2abe561eed7956296c9b471545a808f037c0e38edf7b4c1d8f3238e7b6
5
5
  SHA512:
6
- metadata.gz: d789cd2fe143a465f645f73fb7d6ecc0882ace1bbaadca2513b26053efff52bd1c162aff2fe43ee25e0bd600024366db92a221b855562d3eef4b44025f691daf
7
- data.tar.gz: 94642b199d333cb7da54cc324c0ee29641431bcc4884e38b7e24e6d47eb97f886ece7f680f3923c336c62a55ff171d247d8578abde94e10cee0c5eda8a8759a6
6
+ metadata.gz: c44beed6e0457d616a2c553282a1004ce0b58fe33698b3608c881549b4bd05d7ef603bdb7d06271f74b87c8966d45ed3334792733fae0b3ef94bec2bfaf560d7
7
+ data.tar.gz: a521d2724f4c293465c2357d60f8043ac743f37b26fa690e3a6a31fd7e4a29ad26fcf40adcd4452f84245fc28507ea82122cc0ca5ac2daedeeee33079a7df635
data/.gitignore CHANGED
@@ -1,5 +1,6 @@
1
1
  *.gem
2
2
  .direnv
3
+ kubeconfig.yaml
3
4
  /.bundle/
4
5
  /coverage/
5
6
  /pkg/
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kube_schema (1.3.3)
4
+ kube_schema (1.3.6)
5
5
  json_schemer (~> 2.5.0)
6
6
  rubyshell (~> 1.5.0)
7
7
 
data/Rakefile CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rspec/core/rake_task"
3
+ task :test do
4
+ Dir["lib/**/*.rb"].each { |f| sh "ruby", f }
5
+ end
4
6
 
5
- RSpec::Core::RakeTask.new(:spec)
6
-
7
- task default: :spec
7
+ task default: :test
@@ -40,4 +40,6 @@ result = template.result(binding)
40
40
 
41
41
  File.write(output_path, result)
42
42
 
43
+ `bundle install`
44
+
43
45
  puts "#{current} -> #{version}"
data/bin/test CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
5
-
6
4
  Dir.chdir(File.expand_path("..", __dir__))
7
5
 
8
- if ARGV.empty?
9
- exec("bundle", "exec", "rspec")
10
- else
11
- exec("bundle", "exec", "rspec", *ARGV)
6
+ files = ARGV.empty? ? Dir.glob("lib/**/*.rb").sort : ARGV
7
+
8
+ files.each do |f|
9
+ system("bundle", "exec", "ruby", f, exception: true)
12
10
  end
data/lib/kube/errors.rb CHANGED
@@ -309,3 +309,453 @@ module Kube
309
309
  end
310
310
  end
311
311
  end
312
+
313
+ if __FILE__ == $0
314
+ require "bundler/setup"
315
+ require "rspec/autorun"
316
+ require "kube/schema"
317
+
318
+ RSpec.describe Kube::ValidationError do
319
+ # Helper to build a minimal JSONSchemer-style error hash.
320
+ def error_hash(overrides = {})
321
+ {
322
+ "data" => nil,
323
+ "data_pointer" => "",
324
+ "schema" => {},
325
+ "schema_pointer" => "",
326
+ "root_schema" => {},
327
+ "type" => "unknown",
328
+ "error" => "something went wrong"
329
+ }.merge(overrides)
330
+ end
331
+
332
+ describe "#message" do
333
+ context "header" do
334
+ it "includes kind and name when both are provided" do
335
+ err = described_class.new(
336
+ [error_hash],
337
+ kind: "Deployment",
338
+ name: "web"
339
+ )
340
+ expect(err.message).to include('Schema validation failed for Deployment "web"')
341
+ end
342
+
343
+ it "includes kind without name" do
344
+ err = described_class.new(
345
+ [error_hash],
346
+ kind: "Service"
347
+ )
348
+ expect(err.message).to include("Schema validation failed for Service")
349
+ end
350
+
351
+ it "shows a generic header when no kind or name" do
352
+ err = described_class.new([error_hash])
353
+ expect(err.message).to include("Schema validation failed")
354
+ end
355
+ end
356
+
357
+ context "type errors" do
358
+ %w[string integer number boolean array object null].each do |type|
359
+ it "formats #{type} type mismatch with the actual value" do
360
+ err = described_class.new([
361
+ error_hash(
362
+ "data_pointer" => "/spec/replicas",
363
+ "type" => type,
364
+ "data" => "bad_value"
365
+ )
366
+ ])
367
+ expect(err.message).to include("spec.replicas = \"bad_value\" — expected #{type}, got String")
368
+ end
369
+ end
370
+
371
+ it "shows integer value for string type error" do
372
+ err = described_class.new([
373
+ error_hash(
374
+ "data_pointer" => "/spec/template/image",
375
+ "type" => "string",
376
+ "data" => 2343
377
+ )
378
+ ])
379
+ expect(err.message).to include("spec.template.image = 2343 — expected string, got Integer")
380
+ end
381
+ end
382
+
383
+ context "required errors" do
384
+ it "expands each missing key onto its own line" do
385
+ err = described_class.new([
386
+ error_hash(
387
+ "data_pointer" => "/spec",
388
+ "type" => "required",
389
+ "details" => { "missing_keys" => %w[selector template] }
390
+ )
391
+ ])
392
+ expect(err.message).to include("spec.selector is required but missing")
393
+ expect(err.message).to include("spec.template is required but missing")
394
+ end
395
+
396
+ it "handles required at root level" do
397
+ err = described_class.new([
398
+ error_hash(
399
+ "data_pointer" => "",
400
+ "type" => "required",
401
+ "details" => { "missing_keys" => ["spec"] }
402
+ )
403
+ ])
404
+ expect(err.message).to include("spec is required but missing")
405
+ end
406
+
407
+ it "falls back gracefully if missing_keys is absent" do
408
+ err = described_class.new([
409
+ error_hash(
410
+ "data_pointer" => "/spec",
411
+ "type" => "required",
412
+ "details" => {}
413
+ )
414
+ ])
415
+ # Should not raise, should produce some message
416
+ expect(err.message).to include("spec")
417
+ end
418
+ end
419
+
420
+ context "numeric constraint errors" do
421
+ it "formats minimum violations" do
422
+ err = described_class.new([
423
+ error_hash(
424
+ "data_pointer" => "/spec/replicas",
425
+ "type" => "minimum",
426
+ "data" => -1,
427
+ "schema" => { "minimum" => 0 }
428
+ )
429
+ ])
430
+ expect(err.message).to include("spec.replicas = -1 — must be >= 0")
431
+ end
432
+
433
+ it "formats maximum violations" do
434
+ err = described_class.new([
435
+ error_hash(
436
+ "data_pointer" => "/spec/replicas",
437
+ "type" => "maximum",
438
+ "data" => 999,
439
+ "schema" => { "maximum" => 100 }
440
+ )
441
+ ])
442
+ expect(err.message).to include("spec.replicas = 999 — must be <= 100")
443
+ end
444
+
445
+ it "formats exclusiveMinimum violations" do
446
+ err = described_class.new([
447
+ error_hash(
448
+ "data_pointer" => "/spec/value",
449
+ "type" => "exclusiveMinimum",
450
+ "data" => 0,
451
+ "schema" => { "exclusiveMinimum" => 0 }
452
+ )
453
+ ])
454
+ expect(err.message).to include("spec.value = 0 — must be > 0")
455
+ end
456
+
457
+ it "formats exclusiveMaximum violations" do
458
+ err = described_class.new([
459
+ error_hash(
460
+ "data_pointer" => "/spec/value",
461
+ "type" => "exclusiveMaximum",
462
+ "data" => 100,
463
+ "schema" => { "exclusiveMaximum" => 100 }
464
+ )
465
+ ])
466
+ expect(err.message).to include("spec.value = 100 — must be < 100")
467
+ end
468
+
469
+ it "formats multipleOf violations" do
470
+ err = described_class.new([
471
+ error_hash(
472
+ "data_pointer" => "/spec/count",
473
+ "type" => "multipleOf",
474
+ "data" => 7,
475
+ "schema" => { "multipleOf" => 3 }
476
+ )
477
+ ])
478
+ expect(err.message).to include("spec.count = 7 — must be a multiple of 3")
479
+ end
480
+ end
481
+
482
+ context "string constraint errors" do
483
+ it "formats pattern violations" do
484
+ err = described_class.new([
485
+ error_hash(
486
+ "data_pointer" => "/metadata/name",
487
+ "type" => "pattern",
488
+ "data" => "INVALID",
489
+ "schema" => { "pattern" => "^[a-z0-9-]+$" }
490
+ )
491
+ ])
492
+ expect(err.message).to include('metadata.name = "INVALID" — does not match pattern: ^[a-z0-9-]+$')
493
+ end
494
+
495
+ it "formats minLength violations" do
496
+ err = described_class.new([
497
+ error_hash(
498
+ "data_pointer" => "/metadata/name",
499
+ "type" => "minLength",
500
+ "data" => "",
501
+ "schema" => { "minLength" => 1 }
502
+ )
503
+ ])
504
+ expect(err.message).to include('metadata.name = "" — length must be >= 1')
505
+ end
506
+
507
+ it "formats maxLength violations" do
508
+ err = described_class.new([
509
+ error_hash(
510
+ "data_pointer" => "/metadata/name",
511
+ "type" => "maxLength",
512
+ "data" => "a" * 300,
513
+ "schema" => { "maxLength" => 253 }
514
+ )
515
+ ])
516
+ expect(err.message).to include("metadata.name =")
517
+ expect(err.message).to include("— length must be <= 253")
518
+ end
519
+ end
520
+
521
+ context "enum errors" do
522
+ it "shows allowed values" do
523
+ err = described_class.new([
524
+ error_hash(
525
+ "data_pointer" => "/spec/type",
526
+ "type" => "enum",
527
+ "data" => "InvalidType",
528
+ "schema" => { "enum" => %w[ClusterIP NodePort LoadBalancer] }
529
+ )
530
+ ])
531
+ expect(err.message).to include('"InvalidType" — must be one of:')
532
+ expect(err.message).to include("ClusterIP")
533
+ end
534
+ end
535
+
536
+ context "format errors" do
537
+ it "shows the expected format" do
538
+ err = described_class.new([
539
+ error_hash(
540
+ "data_pointer" => "/spec/startTime",
541
+ "type" => "format",
542
+ "data" => "not-a-date",
543
+ "schema" => { "format" => "date-time" }
544
+ )
545
+ ])
546
+ expect(err.message).to include('spec.startTime = "not-a-date" — invalid date-time format')
547
+ end
548
+ end
549
+
550
+ context "array constraint errors" do
551
+ it "formats minItems violations" do
552
+ err = described_class.new([
553
+ error_hash(
554
+ "data_pointer" => "/spec/containers",
555
+ "type" => "minItems",
556
+ "data" => [],
557
+ "schema" => { "minItems" => 1 }
558
+ )
559
+ ])
560
+ expect(err.message).to include("spec.containers — array must have >= 1 items, got 0")
561
+ end
562
+
563
+ it "formats uniqueItems violations" do
564
+ err = described_class.new([
565
+ error_hash(
566
+ "data_pointer" => "/spec/ports",
567
+ "type" => "uniqueItems",
568
+ "data" => [80, 80]
569
+ )
570
+ ])
571
+ expect(err.message).to include("spec.ports — array items must be unique")
572
+ end
573
+ end
574
+
575
+ context "unknown error types" do
576
+ it "falls back to JSONSchemer's error message" do
577
+ err = described_class.new([
578
+ error_hash(
579
+ "data_pointer" => "/spec/something",
580
+ "type" => "oneOf",
581
+ "error" => "value at `/spec/something` does not match exactly one schema"
582
+ )
583
+ ])
584
+ expect(err.message).to include("spec.something: value at `/spec/something` does not match exactly one schema")
585
+ end
586
+
587
+ it "falls back to the type name when no error message exists" do
588
+ err = described_class.new([
589
+ error_hash(
590
+ "data_pointer" => "/spec/x",
591
+ "type" => "customKeyword",
592
+ "error" => nil
593
+ )
594
+ ])
595
+ expect(err.message).to include("spec.x: customKeyword")
596
+ end
597
+ end
598
+
599
+ context "value truncation" do
600
+ it "truncates long values" do
601
+ long_value = "a" * 200
602
+ err = described_class.new([
603
+ error_hash(
604
+ "data_pointer" => "/spec/data",
605
+ "type" => "integer",
606
+ "data" => long_value
607
+ )
608
+ ])
609
+ expect(err.message).to include("...")
610
+ expect(err.message.length).to be < 500
611
+ end
612
+ end
613
+
614
+ context "dot-notation path conversion" do
615
+ it "converts JSON pointers to dot notation" do
616
+ err = described_class.new([
617
+ error_hash(
618
+ "data_pointer" => "/spec/template/spec/containers/0/image",
619
+ "type" => "string",
620
+ "data" => 123
621
+ )
622
+ ])
623
+ expect(err.message).to include("spec.template.spec.containers.0.image = 123")
624
+ end
625
+
626
+ it "uses 'root' for empty pointer" do
627
+ err = described_class.new([
628
+ error_hash(
629
+ "data_pointer" => "",
630
+ "type" => "object",
631
+ "data" => "not_an_object"
632
+ )
633
+ ])
634
+ expect(err.message).to include("root = ")
635
+ end
636
+ end
637
+ end
638
+
639
+ describe "#errors" do
640
+ it "exposes the raw JSONSchemer error hashes" do
641
+ raw = [error_hash("type" => "string")]
642
+ err = described_class.new(raw)
643
+ expect(err.errors).to equal(raw)
644
+ end
645
+ end
646
+
647
+ describe "annotated manifest output" do
648
+ it "includes the resource YAML when manifest is provided" do
649
+ manifest = {
650
+ "apiVersion" => "apps/v1",
651
+ "kind" => "Deployment",
652
+ "metadata" => { "name" => "web", "namespace" => "prod" },
653
+ "spec" => { "replicas" => "bad" }
654
+ }
655
+ err = described_class.new(
656
+ [error_hash("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
657
+ kind: "Deployment",
658
+ name: "web",
659
+ manifest: manifest
660
+ )
661
+ expect(err.message).to include("apiVersion: apps/v1")
662
+ expect(err.message).to include("kind: Deployment")
663
+ expect(err.message).to include("name: web")
664
+ expect(err.message).to include("namespace: prod")
665
+ expect(err.message).to include("replicas: bad")
666
+ end
667
+
668
+ it "highlights error lines in red (ANSI)" do
669
+ manifest = {
670
+ "apiVersion" => "apps/v1",
671
+ "kind" => "Deployment",
672
+ "spec" => { "replicas" => "bad" }
673
+ }
674
+ err = described_class.new(
675
+ [error_hash("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
676
+ manifest: manifest
677
+ )
678
+ # The replicas line should be wrapped in red ANSI
679
+ expect(err.message).to include("\033[31m")
680
+ expect(err.message).to match(/\033\[31m\s*replicas: bad\033\[0m/)
681
+ end
682
+
683
+ it "appends a yellow inline comment on error lines" do
684
+ manifest = {
685
+ "apiVersion" => "v1",
686
+ "kind" => "Pod",
687
+ "spec" => { "replicas" => "bad" }
688
+ }
689
+ err = described_class.new(
690
+ [error_hash("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
691
+ manifest: manifest
692
+ )
693
+ expect(err.message).to match(/\033\[33m# expected integer, got String\033\[0m/)
694
+ end
695
+
696
+ it "injects missing required keys as red lines" do
697
+ manifest = {
698
+ "apiVersion" => "apps/v1",
699
+ "kind" => "Deployment",
700
+ "spec" => { "replicas" => 3 }
701
+ }
702
+ err = described_class.new(
703
+ [error_hash(
704
+ "data_pointer" => "/spec",
705
+ "type" => "required",
706
+ "details" => { "missing_keys" => ["selector"] }
707
+ )],
708
+ manifest: manifest
709
+ )
710
+ expect(err.message).to match(/\033\[31m\s*selector:\033\[0m/)
711
+ expect(err.message).to include("MISSING")
712
+ end
713
+
714
+ it "does not color lines that have no errors" do
715
+ manifest = {
716
+ "apiVersion" => "apps/v1",
717
+ "kind" => "Deployment",
718
+ "metadata" => { "name" => "web" },
719
+ "spec" => { "replicas" => "bad" }
720
+ }
721
+ err = described_class.new(
722
+ [error_hash("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
723
+ manifest: manifest
724
+ )
725
+ # The "name: web" line should NOT have red ANSI
726
+ err.message.each_line do |line|
727
+ next unless line.include?("name: web")
728
+ expect(line).not_to include("\033[31m")
729
+ end
730
+ end
731
+
732
+ it "handles array items with correct pointer mapping" do
733
+ manifest = {
734
+ "apiVersion" => "v1",
735
+ "kind" => "Pod",
736
+ "spec" => {
737
+ "containers" => [
738
+ { "name" => "app", "image" => 2343 }
739
+ ]
740
+ }
741
+ }
742
+ err = described_class.new(
743
+ [error_hash(
744
+ "data_pointer" => "/spec/containers/0/image",
745
+ "type" => "string",
746
+ "data" => 2343
747
+ )],
748
+ manifest: manifest
749
+ )
750
+ expect(err.message).to match(/\033\[31m\s*image: 2343\033\[0m/)
751
+ end
752
+
753
+ it "omits the manifest section when manifest is nil" do
754
+ err = described_class.new(
755
+ [error_hash("data_pointer" => "/spec/x", "type" => "string", "data" => 1)]
756
+ )
757
+ expect(err.message).not_to include("---")
758
+ end
759
+ end
760
+ end
761
+ end
@@ -20,4 +20,17 @@ class Hash
20
20
  #def respond_to_missing(name, _priv = false)
21
21
  # name.to_s.end_with?("=") || key?(name.to_sym)
22
22
  #end
23
+
24
+ # Build a Hash with autovivification via a block DSL.
25
+ #
26
+ # Hash.vivify {
27
+ # metadata.name = "web"
28
+ # spec.replicas = 3
29
+ # spec.selector.matchLabels.app = "web"
30
+ # }
31
+ # # => { metadata: { name: "web" }, spec: { replicas: 3, selector: { matchLabels: { app: "web" } } } }
32
+ #
33
+ def self.vivify(&block)
34
+ new.tap { |h| h.instance_exec(&block) }
35
+ end
23
36
  end
@@ -222,3 +222,103 @@ module Kube
222
222
  end
223
223
  end
224
224
  end
225
+
226
+ if __FILE__ == $0
227
+ require "bundler/setup"
228
+ require "rspec/autorun"
229
+ require "kube/schema"
230
+
231
+ RSpec.describe Kube::Schema::Instance do
232
+ subject(:instance) { described_class.new("1.34") }
233
+
234
+ describe "#initialize" do
235
+ it "stores the version" do
236
+ expect(instance.version).to eq("1.34")
237
+ end
238
+
239
+ it "raises UnknownVersionError for a non-version string" do
240
+ expect { described_class.new("not-a-version") }.to raise_error(Kube::UnknownVersionError)
241
+ end
242
+ end
243
+
244
+ describe "#[]" do
245
+ it "returns a Class that subclasses Resource" do
246
+ klass = instance["Deployment"]
247
+ expect(klass).to be_a(Class)
248
+ expect(klass).to be < Kube::Schema::Resource
249
+ end
250
+
251
+ it "caches resource classes by key" do
252
+ a = instance["Deployment"]
253
+ b = instance["Deployment"]
254
+ expect(a).to be(b)
255
+ end
256
+
257
+ it "raises for an unknown resource" do
258
+ expect { instance["ThisDoesNotExist999"] }.to raise_error(RuntimeError, /No resource schema found/)
259
+ end
260
+
261
+ it "returns different classes for different resources" do
262
+ deployment = instance["Deployment"]
263
+ service = instance["Service"]
264
+ expect(deployment).not_to eq(service)
265
+ end
266
+
267
+ it "attaches a schema to the resource class" do
268
+ klass = instance["Deployment"]
269
+ expect(klass.schema).not_to be_nil
270
+ end
271
+
272
+ it "is case-insensitive" do
273
+ a = instance["Deployment"]
274
+ b = instance["deployment"]
275
+ expect(a).to be < Kube::Schema::Resource
276
+ expect(b).to be < Kube::Schema::Resource
277
+ end
278
+
279
+ it "produces resources that can be instantiated with the block DSL" do
280
+ resource = instance["Deployment"].new {
281
+ metadata.name = "test"
282
+ }
283
+ expect(resource.to_h[:apiVersion]).to eq("apps/v1")
284
+ expect(resource.to_h[:kind]).to eq("Deployment")
285
+ expect(resource.to_h[:metadata][:name]).to eq("test")
286
+ end
287
+
288
+ it "attaches defaults (apiVersion and kind) to the resource class" do
289
+ klass = instance["Deployment"]
290
+ expect(klass.defaults).to eq({ "apiVersion" => "apps/v1", "kind" => "Deployment" })
291
+ end
292
+
293
+ it "derives correct apiVersion for core resources (empty group)" do
294
+ klass = instance["Pod"]
295
+ expect(klass.defaults).to eq({ "apiVersion" => "v1", "kind" => "Pod" })
296
+ end
297
+
298
+ it "derives correct apiVersion for grouped resources" do
299
+ klass = instance["Ingress"]
300
+ expect(klass.defaults).to eq({ "apiVersion" => "networking.k8s.io/v1", "kind" => "Ingress" })
301
+ end
302
+ end
303
+
304
+ describe "#list_resources" do
305
+ it "returns a sorted array of kind strings" do
306
+ kinds = instance.list_resources
307
+ expect(kinds).to be_an(Array)
308
+ expect(kinds).not_to be_empty
309
+ expect(kinds).to include("Deployment", "Service", "Namespace", "Pod")
310
+ expect(kinds).to eq(kinds.sort)
311
+ end
312
+ end
313
+
314
+ describe "class-level schemer cache" do
315
+ it "shares the schemer across instances of the same version" do
316
+ a = described_class.new("1.34")
317
+ b = described_class.new("1.34")
318
+ # Both should resolve without error and return equivalent classes
319
+ expect(a["Deployment"]).to be < Kube::Schema::Resource
320
+ expect(b["Deployment"]).to be < Kube::Schema::Resource
321
+ end
322
+ end
323
+ end
324
+ end