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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile.lock +1 -1
- data/Rakefile +4 -4
- data/bin/increment-version +2 -0
- data/bin/test +4 -6
- data/lib/kube/errors.rb +450 -0
- data/lib/kube/schema/instance.rb +119 -0
- data/lib/kube/schema/manifest.rb +359 -2
- data/lib/kube/schema/resource.rb +441 -13
- data/lib/kube/schema/version.rb +1 -1
- data/lib/kube/schema.rb +173 -8
- data/schemas/crd-definitions.json +220478 -595
- metadata +1 -2
- data/AGENTS.md +0 -1
data/lib/kube/schema/resource.rb
CHANGED
|
@@ -11,12 +11,16 @@ module Kube
|
|
|
11
11
|
# Therefore, they are ONLY ever set from the self.defaults
|
|
12
12
|
# property.
|
|
13
13
|
deep_symbolize_keys(hash).then do |symbolized|
|
|
14
|
-
|
|
15
|
-
metadata: symbolized.delete(:metadata) || {},
|
|
16
|
-
spec: symbolized.delete(:spec) || {},
|
|
17
|
-
})
|
|
14
|
+
@data = defaults
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
# This is extracting "top-level" properties from the input hash
|
|
17
|
+
# such as [apiVersion, spec, metadata, roleRef, ...]
|
|
18
|
+
# We then ignore the rest of the attributes by design.
|
|
19
|
+
self.class.schema_properties.each do |property|
|
|
20
|
+
if symbolized.key?(property)
|
|
21
|
+
@data[property] = symbolized.delete(property)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
22
26
|
|
|
@@ -25,17 +29,20 @@ module Kube
|
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
|
|
28
|
-
def apiVersion = @data.apiVersion
|
|
29
|
-
def kind = @data.kind
|
|
30
|
-
def spec = @data.spec
|
|
31
|
-
def metadata = @data.metadata
|
|
32
|
-
|
|
33
32
|
# Gets overridden by the factory in Kube::Schema::Instance
|
|
34
|
-
def self.schema
|
|
33
|
+
def self.schema
|
|
34
|
+
raise "Kube::Schema::Resource should NOT be instanciated directly"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.schema_properties
|
|
38
|
+
raise "Kube::Schema::Resource should NOT be instanciated directly"
|
|
39
|
+
end
|
|
35
40
|
|
|
36
41
|
# Gets overridden by the factory in Kube::Schema::Instance.
|
|
37
42
|
# Returns a frozen Hash like { "apiVersion" => "apps/v1", "kind" => "Deployment" }
|
|
38
|
-
def self.defaults
|
|
43
|
+
def self.defaults
|
|
44
|
+
raise "Kube::Schema::Resource should NOT be instanciated directly"
|
|
45
|
+
end
|
|
39
46
|
|
|
40
47
|
def valid?
|
|
41
48
|
if self.class.schema.nil?
|
|
@@ -69,7 +76,8 @@ module Kube
|
|
|
69
76
|
# they are facts derived from the GVK metadata.
|
|
70
77
|
def to_h
|
|
71
78
|
defaults = self.class.defaults
|
|
72
|
-
data = @data
|
|
79
|
+
data = deep_compact(@data)
|
|
80
|
+
data = data.reject { |_, v| v.is_a?(Hash) && v.empty? }
|
|
73
81
|
|
|
74
82
|
if defaults
|
|
75
83
|
symbolized = deep_symbolize_keys(defaults)
|
|
@@ -95,6 +103,20 @@ module Kube
|
|
|
95
103
|
|
|
96
104
|
private
|
|
97
105
|
|
|
106
|
+
def deep_compact(obj)
|
|
107
|
+
case obj
|
|
108
|
+
when Hash
|
|
109
|
+
obj.each_with_object({}) do |(k, v), result|
|
|
110
|
+
compacted = deep_compact(v)
|
|
111
|
+
result[k] = compacted unless compacted.nil?
|
|
112
|
+
end
|
|
113
|
+
when Array
|
|
114
|
+
obj.map { |v| deep_compact(v) }
|
|
115
|
+
else
|
|
116
|
+
obj
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
98
120
|
def deep_stringify_keys(obj)
|
|
99
121
|
case obj
|
|
100
122
|
when Hash
|
|
@@ -123,3 +145,409 @@ module Kube
|
|
|
123
145
|
end
|
|
124
146
|
end
|
|
125
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
|
data/lib/kube/schema/version.rb
CHANGED
data/lib/kube/schema.rb
CHANGED
|
@@ -48,11 +48,11 @@ module Kube
|
|
|
48
48
|
# api_version: "cert-manager.io/v1"
|
|
49
49
|
# )
|
|
50
50
|
#
|
|
51
|
-
# @example Register from
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# api_version:
|
|
55
|
-
#
|
|
51
|
+
# @example Register from Chart#crds
|
|
52
|
+
# chart.crds.each do |crd|
|
|
53
|
+
# s = crd.to_json_schema
|
|
54
|
+
# Kube::Schema.register(s[:kind], schema: s[:schema], api_version: s[:api_version])
|
|
55
|
+
# end
|
|
56
56
|
#
|
|
57
57
|
def register(kind, schema:, api_version:)
|
|
58
58
|
require "json"
|
|
@@ -117,10 +117,26 @@ module Kube
|
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
# Build a Resource from a hash.
|
|
121
|
-
#
|
|
120
|
+
# Build a typed Resource from a raw hash.
|
|
121
|
+
#
|
|
122
|
+
# Looks up the "kind" key in the hash and resolves it to the
|
|
123
|
+
# correct Resource subclass via the schema registry. The hash
|
|
124
|
+
# may use string or symbol keys.
|
|
125
|
+
#
|
|
126
|
+
# Kube::Schema.parse("kind" => "Deployment", "apiVersion" => "apps/v1")
|
|
127
|
+
# Kube::Schema.parse(kind: "Pod", apiVersion: "v1", metadata: { name: "web" })
|
|
128
|
+
#
|
|
129
|
+
# @param hash [Hash] a Kubernetes resource hash with at least a "kind" key
|
|
130
|
+
# @return [Resource] a schema-validated Resource instance
|
|
131
|
+
# @raise [ArgumentError] if the hash is nil, not a Hash, or missing "kind"
|
|
122
132
|
def parse(hash)
|
|
123
|
-
raise
|
|
133
|
+
raise ArgumentError, "Expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
kind = hash["kind"] || hash[:kind]
|
|
136
|
+
raise ArgumentError, "Hash must contain a \"kind\" key" if kind.nil?
|
|
137
|
+
|
|
138
|
+
resource_class = self[kind]
|
|
139
|
+
resource_class.new(hash)
|
|
124
140
|
end
|
|
125
141
|
|
|
126
142
|
# Available Kubernetes versions, read from the local schemas directory.
|
|
@@ -192,3 +208,152 @@ class BlackHoleStruct
|
|
|
192
208
|
end
|
|
193
209
|
end
|
|
194
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
|