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/manifest.rb
CHANGED
|
@@ -90,8 +90,32 @@ module Kube
|
|
|
90
90
|
# File I/O
|
|
91
91
|
# -------------------------------------------------------------------
|
|
92
92
|
|
|
93
|
+
# Parse a YAML string containing one or more Kubernetes resource
|
|
94
|
+
# documents and return a Manifest populated with typed Resource objects.
|
|
95
|
+
#
|
|
96
|
+
# Each document's "kind" is resolved via Kube::Schema.parse to
|
|
97
|
+
# produce the correct Resource subclass (e.g. Deployment, Service).
|
|
98
|
+
# Documents without a recognized "kind" fall back to a bare Resource.
|
|
99
|
+
#
|
|
100
|
+
# yaml = `helm template my-release bitnami/nginx`
|
|
101
|
+
# manifest = Kube::Schema::Manifest.parse(yaml)
|
|
102
|
+
# manifest.first.class #=> Kube::Schema::Resource (Deployment subclass)
|
|
103
|
+
#
|
|
104
|
+
# @param yaml_string [String] multi-document YAML
|
|
105
|
+
# @return [Manifest]
|
|
106
|
+
def self.parse(yaml_string)
|
|
107
|
+
docs = if YAML.respond_to?(:safe_load_stream)
|
|
108
|
+
YAML.safe_load_stream(yaml_string, permitted_classes: [Symbol])
|
|
109
|
+
else
|
|
110
|
+
YAML.load_stream(yaml_string)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
resources = docs.compact.map { |doc| parse_doc(doc) }
|
|
114
|
+
new(*resources)
|
|
115
|
+
end
|
|
116
|
+
|
|
93
117
|
# Read a YAML file containing one or more Kubernetes resource documents
|
|
94
|
-
# and return a Manifest populated with Resource objects.
|
|
118
|
+
# and return a Manifest populated with typed Resource objects.
|
|
95
119
|
#
|
|
96
120
|
# manifest = Kube::Schema::Manifest.open("deploy.yaml")
|
|
97
121
|
# manifest.count #=> 3
|
|
@@ -108,7 +132,7 @@ module Kube
|
|
|
108
132
|
YAML.load_stream(contents)
|
|
109
133
|
end
|
|
110
134
|
|
|
111
|
-
resources = docs.compact.map { |doc|
|
|
135
|
+
resources = docs.compact.map { |doc| parse_doc(doc) }
|
|
112
136
|
new(*resources, filename: path)
|
|
113
137
|
end
|
|
114
138
|
|
|
@@ -127,6 +151,16 @@ module Kube
|
|
|
127
151
|
path
|
|
128
152
|
end
|
|
129
153
|
|
|
154
|
+
# Parse a single YAML document hash into a typed Resource.
|
|
155
|
+
#
|
|
156
|
+
# @param doc [Hash] a parsed YAML document
|
|
157
|
+
# @return [Resource]
|
|
158
|
+
# @raise [RuntimeError] if the kind is not recognized
|
|
159
|
+
def self.parse_doc(doc)
|
|
160
|
+
Kube::Schema.parse(doc)
|
|
161
|
+
end
|
|
162
|
+
private_class_method :parse_doc
|
|
163
|
+
|
|
130
164
|
private
|
|
131
165
|
|
|
132
166
|
# Deep-stringify keys for clean YAML output.
|
|
@@ -146,3 +180,326 @@ module Kube
|
|
|
146
180
|
end
|
|
147
181
|
end
|
|
148
182
|
end
|
|
183
|
+
|
|
184
|
+
if __FILE__ == $0
|
|
185
|
+
require "bundler/setup"
|
|
186
|
+
require "rspec/autorun"
|
|
187
|
+
require "kube/schema"
|
|
188
|
+
require "tmpdir"
|
|
189
|
+
|
|
190
|
+
RSpec.describe Kube::Schema::Manifest do
|
|
191
|
+
let(:resource_a) { Kube::Schema["Deployment"].new }
|
|
192
|
+
let(:resource_b) { Kube::Schema["Service"].new }
|
|
193
|
+
let(:resource_c) { Kube::Schema["Namespace"].new }
|
|
194
|
+
|
|
195
|
+
describe "#initialize" do
|
|
196
|
+
it "creates an empty manifest with no arguments" do
|
|
197
|
+
manifest = described_class.new
|
|
198
|
+
expect(manifest.count).to eq(0)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "accepts resources as arguments" do
|
|
202
|
+
manifest = described_class.new(resource_a, resource_b)
|
|
203
|
+
expect(manifest.count).to eq(2)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "accepts a filename keyword argument" do
|
|
207
|
+
manifest = described_class.new(filename: "/tmp/test.yaml")
|
|
208
|
+
expect(manifest.filename).to eq("/tmp/test.yaml")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "flattens manifests passed as arguments" do
|
|
212
|
+
inner = described_class.new(resource_a, resource_b)
|
|
213
|
+
outer = described_class.new(inner, resource_c)
|
|
214
|
+
expect(outer.count).to eq(3)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
describe "#<<" do
|
|
219
|
+
subject(:manifest) { described_class.new }
|
|
220
|
+
|
|
221
|
+
it "appends a Resource" do
|
|
222
|
+
manifest << resource_a
|
|
223
|
+
expect(manifest.count).to eq(1)
|
|
224
|
+
expect(manifest.first).to eq(resource_a)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "returns self for chaining" do
|
|
228
|
+
result = manifest << resource_a
|
|
229
|
+
expect(result).to be(manifest)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "flattens a Manifest (cannot be nested)" do
|
|
233
|
+
other = described_class.new(resource_a, resource_b)
|
|
234
|
+
manifest << other
|
|
235
|
+
expect(manifest.count).to eq(2)
|
|
236
|
+
expect(manifest.to_a).to contain_exactly(resource_a, resource_b)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "flattens an Array of Resources" do
|
|
240
|
+
manifest << [resource_a, resource_b]
|
|
241
|
+
expect(manifest.count).to eq(2)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "raises ArgumentError for a Hash" do
|
|
245
|
+
expect { manifest << { "kind" => "Pod" } }.to raise_error(ArgumentError, /Expected a Kube::Schema::Resource/)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "raises ArgumentError for a String" do
|
|
249
|
+
expect { manifest << "not a resource" }.to raise_error(ArgumentError)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "supports chaining multiple appends" do
|
|
253
|
+
manifest << resource_a << resource_b << resource_c
|
|
254
|
+
expect(manifest.count).to eq(3)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
describe "Enumerable" do
|
|
259
|
+
subject(:manifest) { described_class.new(resource_a, resource_b, resource_c) }
|
|
260
|
+
|
|
261
|
+
it "includes Enumerable" do
|
|
262
|
+
expect(described_class).to include(Enumerable)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "yields resources in insertion order via #each" do
|
|
266
|
+
resources = []
|
|
267
|
+
manifest.each { |r| resources << r }
|
|
268
|
+
expect(resources).to eq([resource_a, resource_b, resource_c])
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it "supports #map" do
|
|
272
|
+
kinds = manifest.map { |r| r.to_h[:kind] }
|
|
273
|
+
expect(kinds).to eq(["Deployment", "Service", "Namespace"])
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "supports #select" do
|
|
277
|
+
services = manifest.select { |r| r.to_h[:kind] == "Service" }
|
|
278
|
+
expect(services.length).to eq(1)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "supports #first and #last" do
|
|
282
|
+
expect(manifest.first).to eq(resource_a)
|
|
283
|
+
# Enumerable doesn't provide #last by default, but #to_a does
|
|
284
|
+
expect(manifest.to_a.last).to eq(resource_c)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
describe "#size / #length" do
|
|
289
|
+
it "returns the number of resources" do
|
|
290
|
+
manifest = described_class.new(resource_a, resource_b)
|
|
291
|
+
expect(manifest.size).to eq(2)
|
|
292
|
+
expect(manifest.length).to eq(2)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "returns 0 for an empty manifest" do
|
|
296
|
+
manifest = described_class.new
|
|
297
|
+
expect(manifest.size).to eq(0)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
describe "#to_a" do
|
|
302
|
+
it "returns a copy of the internal resources array" do
|
|
303
|
+
manifest = described_class.new(resource_a, resource_b)
|
|
304
|
+
arr = manifest.to_a
|
|
305
|
+
expect(arr).to eq([resource_a, resource_b])
|
|
306
|
+
|
|
307
|
+
# Verify it's a copy, not the internal array
|
|
308
|
+
arr << resource_c
|
|
309
|
+
expect(manifest.count).to eq(2)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
describe "#to_yaml" do
|
|
314
|
+
it "returns multi-document YAML" do
|
|
315
|
+
manifest = described_class.new(resource_a, resource_b)
|
|
316
|
+
yaml = manifest.to_yaml
|
|
317
|
+
|
|
318
|
+
expect(yaml).to include("---")
|
|
319
|
+
expect(yaml).to include("kind: Deployment")
|
|
320
|
+
expect(yaml).to include("kind: Service")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it "uses string keys (not symbol keys) in output" do
|
|
324
|
+
manifest = described_class.new(resource_a)
|
|
325
|
+
yaml = manifest.to_yaml
|
|
326
|
+
|
|
327
|
+
# Should NOT contain Ruby symbol syntax like `:kind:`
|
|
328
|
+
expect(yaml).not_to match(/:\w+:/)
|
|
329
|
+
expect(yaml).to include("kind: Deployment")
|
|
330
|
+
expect(yaml).to include("apiVersion: apps/v1")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it "returns empty string for an empty manifest" do
|
|
334
|
+
manifest = described_class.new
|
|
335
|
+
expect(manifest.to_yaml).to eq("")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "produces parseable YAML that round-trips" do
|
|
339
|
+
manifest = described_class.new(resource_a, resource_b)
|
|
340
|
+
yaml_output = manifest.to_yaml
|
|
341
|
+
docs = if YAML.respond_to?(:safe_load_stream)
|
|
342
|
+
YAML.safe_load_stream(yaml_output)
|
|
343
|
+
else
|
|
344
|
+
YAML.load_stream(yaml_output)
|
|
345
|
+
end
|
|
346
|
+
expect(docs.length).to eq(2)
|
|
347
|
+
expect(docs[0]["kind"]).to eq("Deployment")
|
|
348
|
+
expect(docs[1]["kind"]).to eq("Service")
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
describe ".parse" do
|
|
353
|
+
it "parses a single-document YAML string" do
|
|
354
|
+
yaml = { "kind" => "Pod", "apiVersion" => "v1", "metadata" => { "name" => "test" } }.to_yaml
|
|
355
|
+
manifest = described_class.parse(yaml)
|
|
356
|
+
|
|
357
|
+
expect(manifest.count).to eq(1)
|
|
358
|
+
expect(manifest.first).to be_a(Kube::Schema::Resource)
|
|
359
|
+
expect(manifest.first.kind).to eq("Pod")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "parses a multi-document YAML string" do
|
|
363
|
+
yaml = [
|
|
364
|
+
{ "kind" => "Deployment", "apiVersion" => "apps/v1" },
|
|
365
|
+
{ "kind" => "Service", "apiVersion" => "v1" }
|
|
366
|
+
].map(&:to_yaml).join("")
|
|
367
|
+
|
|
368
|
+
manifest = described_class.parse(yaml)
|
|
369
|
+
expect(manifest.count).to eq(2)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "returns typed Resource subclasses" do
|
|
373
|
+
yaml = { "kind" => "Deployment", "apiVersion" => "apps/v1", "metadata" => { "name" => "web" } }.to_yaml
|
|
374
|
+
manifest = described_class.parse(yaml)
|
|
375
|
+
|
|
376
|
+
resource = manifest.first
|
|
377
|
+
expect(resource.class.defaults).to eq({ "apiVersion" => "apps/v1", "kind" => "Deployment" })
|
|
378
|
+
expect(resource.kind).to eq("Deployment")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "skips nil documents (empty YAML docs)" do
|
|
382
|
+
yaml = "---\nkind: Pod\napiVersion: v1\n---\n---\nkind: Service\napiVersion: v1\n"
|
|
383
|
+
manifest = described_class.parse(yaml)
|
|
384
|
+
expect(manifest.count).to eq(2)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it "raises for unknown kinds" do
|
|
388
|
+
yaml = { "kind" => "UnknownCRD", "apiVersion" => "custom.io/v1" }.to_yaml
|
|
389
|
+
expect { described_class.parse(yaml) }.to raise_error(RuntimeError)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it "does not set a filename" do
|
|
393
|
+
yaml = { "kind" => "Pod", "apiVersion" => "v1" }.to_yaml
|
|
394
|
+
manifest = described_class.parse(yaml)
|
|
395
|
+
expect(manifest.filename).to be_nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "returns an empty manifest for empty YAML" do
|
|
399
|
+
manifest = described_class.parse("---\n")
|
|
400
|
+
expect(manifest.count).to eq(0)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
describe ".open" do
|
|
405
|
+
let(:tmpdir) { Dir.mktmpdir("manifest_test") }
|
|
406
|
+
let(:yaml_path) { File.join(tmpdir, "resources.yaml") }
|
|
407
|
+
|
|
408
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
409
|
+
|
|
410
|
+
it "reads a single-document YAML file" do
|
|
411
|
+
File.write(yaml_path, { "kind" => "Pod", "apiVersion" => "v1" }.to_yaml)
|
|
412
|
+
|
|
413
|
+
manifest = described_class.open(yaml_path)
|
|
414
|
+
expect(manifest.count).to eq(1)
|
|
415
|
+
expect(manifest.first).to be_a(Kube::Schema::Resource)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it "reads a multi-document YAML file" do
|
|
419
|
+
content = [
|
|
420
|
+
{ "kind" => "Deployment", "apiVersion" => "apps/v1" },
|
|
421
|
+
{ "kind" => "Service", "apiVersion" => "v1" }
|
|
422
|
+
].map(&:to_yaml).join("")
|
|
423
|
+
|
|
424
|
+
File.write(yaml_path, content)
|
|
425
|
+
|
|
426
|
+
manifest = described_class.open(yaml_path)
|
|
427
|
+
expect(manifest.count).to eq(2)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
it "sets the filename" do
|
|
431
|
+
File.write(yaml_path, { "kind" => "Pod" }.to_yaml)
|
|
432
|
+
|
|
433
|
+
manifest = described_class.open(yaml_path)
|
|
434
|
+
expect(manifest.filename).to eq(yaml_path)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it "skips nil documents (empty YAML docs)" do
|
|
438
|
+
File.write(yaml_path, "---\nkind: Pod\n---\n---\nkind: Service\n")
|
|
439
|
+
|
|
440
|
+
manifest = described_class.open(yaml_path)
|
|
441
|
+
expect(manifest.count).to eq(2)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
describe "#write" do
|
|
446
|
+
let(:tmpdir) { Dir.mktmpdir("manifest_test") }
|
|
447
|
+
|
|
448
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
449
|
+
|
|
450
|
+
it "writes to the given path" do
|
|
451
|
+
path = File.join(tmpdir, "output.yaml")
|
|
452
|
+
manifest = described_class.new(resource_a)
|
|
453
|
+
|
|
454
|
+
manifest.write(path)
|
|
455
|
+
expect(File.exist?(path)).to be true
|
|
456
|
+
|
|
457
|
+
content = File.read(path)
|
|
458
|
+
expect(content).to include("kind: Deployment")
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
it "writes to the stored filename when no path is given" do
|
|
462
|
+
path = File.join(tmpdir, "stored.yaml")
|
|
463
|
+
manifest = described_class.new(resource_a, filename: path)
|
|
464
|
+
|
|
465
|
+
manifest.write
|
|
466
|
+
expect(File.exist?(path)).to be true
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
it "updates the filename after writing to a new path" do
|
|
470
|
+
path = File.join(tmpdir, "new.yaml")
|
|
471
|
+
manifest = described_class.new(resource_a)
|
|
472
|
+
|
|
473
|
+
manifest.write(path)
|
|
474
|
+
expect(manifest.filename).to eq(path)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
it "returns the path written to" do
|
|
478
|
+
path = File.join(tmpdir, "result.yaml")
|
|
479
|
+
manifest = described_class.new(resource_a)
|
|
480
|
+
|
|
481
|
+
result = manifest.write(path)
|
|
482
|
+
expect(result).to eq(path)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it "raises ArgumentError when no path is available" do
|
|
486
|
+
manifest = described_class.new(resource_a)
|
|
487
|
+
expect { manifest.write }.to raise_error(ArgumentError, /No filename set/)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it "round-trips through write and open" do
|
|
491
|
+
path = File.join(tmpdir, "roundtrip.yaml")
|
|
492
|
+
original = described_class.new(resource_a, resource_b)
|
|
493
|
+
|
|
494
|
+
original.write(path)
|
|
495
|
+
loaded = described_class.open(path)
|
|
496
|
+
|
|
497
|
+
expect(loaded.count).to eq(original.count)
|
|
498
|
+
|
|
499
|
+
content = File.read(path)
|
|
500
|
+
expect(content).to include("kind: Deployment")
|
|
501
|
+
expect(content).to include("kind: Service")
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|