kube_schema 1.3.2 → 1.3.5

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: d74795f238283bb03bba7c2a82459475512cf3922704870aa2256f6235410d66
4
- data.tar.gz: 19d329b7abbad3dbf5d37daa8202a457ec14c26f23a078bafaf1e1f10c67abe1
3
+ metadata.gz: a57b14228a491e69ae291d85208d7a4d9ab0ff4dfe0d1c8fe516dee313da896d
4
+ data.tar.gz: f7ca2465bc0a0867db00d3f8dd6e28235f86a626746fa6c0fad35bedc153d377
5
5
  SHA512:
6
- metadata.gz: f11cfd8ce0f92d2c7b824eba56559860a95eca65e2c13ffbcd8177f296b26fabcf395fb66c16d8d4533fbb188e81698f4560d8f3a5f08d2536b2a8d36557eff7
7
- data.tar.gz: 7af51b827644818da72e031e858f47697678dc8d97929c7d2e5bfcd964e2202ede1507f0f20e63ba64dec6a2bebdb0e2128a99a0085fa3562ce3fac1a8d241d0
6
+ metadata.gz: 05decddd173ef72baa520b33d00749bafe275c20411dda61984855b0a000446bc29731d861246b529b5d69148419631741af7e3485a1fcd2f019aa69d30840ec
7
+ data.tar.gz: 53aad4ff4cbc381fde9b8f41c6e2b0744c3e7427bb9e908e33d741450b15c751a8f827727ce229425bedaecf764e0572ef82915a8d9b885c46572ef6b7f57e27
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.1)
4
+ kube_schema (1.3.5)
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
@@ -101,6 +101,14 @@ module Kube
101
101
  schema["definitions"].merge!(extra)
102
102
  end
103
103
 
104
+ # Kubernetes OpenAPI v2 defines IntOrString as "type": "string"
105
+ # with "format": "int-or-string". This is a limitation of
106
+ # OpenAPI v2 which cannot express union types. Patch the
107
+ # definition so JSONSchemer accepts both integers and strings.
108
+ if (int_or_str = schema.dig("definitions", "io.k8s.apimachinery.pkg.util.intstr.IntOrString"))
109
+ int_or_str["type"] = ["string", "integer"]
110
+ end
111
+
104
112
  JSONSchemer.schema(schema)
105
113
  end
106
114
  end
@@ -184,6 +192,7 @@ module Kube
184
192
  Class.new(::Kube::Schema::Resource) do
185
193
  @schema = schema_instance
186
194
  @defaults = defaults
195
+ @schema_properties = @schema.value["properties"].keys.map(&:to_sym)
187
196
 
188
197
  def self.schema
189
198
  @schema || superclass.schema
@@ -192,6 +201,16 @@ module Kube
192
201
  def self.defaults
193
202
  @defaults || superclass.defaults
194
203
  end
204
+
205
+ def self.schema_properties
206
+ @schema_properties
207
+ end
208
+
209
+ schema_instance.value["properties"].keys.then do |properties|
210
+ properties.each do |prop|
211
+ define_method(prop.to_sym) { @data[prop.to_sym] }
212
+ end
213
+ end
195
214
  end
196
215
  end
197
216
 
@@ -203,3 +222,103 @@ module Kube
203
222
  end
204
223
  end
205
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