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.
@@ -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| Resource.new(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