kube_cluster 0.3.7 → 0.3.9

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.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler/setup"
4
+ require "kube/cluster"
5
+
3
6
  module Kube
4
7
  module Cluster
5
8
  class Resource < Kube::Schema::Resource
@@ -111,3 +114,535 @@ module Kube
111
114
  end
112
115
  end
113
116
  end
117
+
118
+ test do
119
+ require "json"
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Fake ctl that records every command and returns canned responses.
123
+ # The test wires this into the cluster → connection → ctl chain so that
124
+ # Persistence#kubectl goes through it without touching a real cluster.
125
+ # ---------------------------------------------------------------------------
126
+ class FakeCtl
127
+ attr_reader :commands
128
+
129
+ def initialize
130
+ @commands = []
131
+ @responses = {}
132
+ end
133
+
134
+ # Queue a response for the next command that includes +substring+.
135
+ def stub_response(substring, response)
136
+ @responses[substring] = response
137
+ end
138
+
139
+ def run(string)
140
+ @commands << string
141
+
142
+ @responses.each do |substring, response|
143
+ if string.include?(substring)
144
+ return response
145
+ end
146
+ end
147
+
148
+ "" # default: empty response
149
+ end
150
+ end
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Minimal cluster double that provides .connection.ctl
154
+ # ---------------------------------------------------------------------------
155
+ class FakeConnection
156
+ attr_reader :ctl
157
+
158
+ def initialize(ctl)
159
+ @ctl = ctl
160
+ end
161
+ end
162
+
163
+ class FakeCluster
164
+ attr_reader :connection
165
+
166
+ def initialize(ctl)
167
+ @connection = FakeConnection.new(ctl)
168
+ end
169
+ end
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Helper to build a resource wired to a fake cluster.
173
+ # ---------------------------------------------------------------------------
174
+ module ResourceHelper
175
+ def build_resource(hash = {})
176
+ ctl = FakeCtl.new
177
+ cluster = FakeCluster.new(ctl)
178
+ resource = Kube::Cluster["ConfigMap"].new(hash.merge(kind: "ConfigMap", cluster: cluster))
179
+ [resource, ctl]
180
+ end
181
+
182
+ # Simulate what kubectl returns: the server adds extra fields.
183
+ def server_state(resource_hash, extra = {})
184
+ merged = resource_hash.merge(extra)
185
+ JSON.generate(stringify_keys(merged))
186
+ end
187
+
188
+ private
189
+
190
+ def stringify_keys(obj)
191
+ case obj
192
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys(v) }
193
+ when Array then obj.map { |v| stringify_keys(v) }
194
+ else obj
195
+ end
196
+ end
197
+ end
198
+
199
+ include ResourceHelper
200
+
201
+ # -------------------------------------------------------------------------
202
+ # Full lifecycle: apply → mutate → detect changes → patch → clean
203
+ # -------------------------------------------------------------------------
204
+
205
+ it "full_apply_mutate_patch_lifecycle" do
206
+ resource, ctl = build_resource(metadata: { name: "app-config", namespace: "production" }, spec: { key: "original" })
207
+
208
+ # Stub the reload after apply — server echoes back what we sent
209
+ ctl.stub_response("get", server_state(
210
+ metadata: { name: "app-config", namespace: "production", resourceVersion: "100" },
211
+ spec: { key: "original" }
212
+ ))
213
+
214
+ resource.apply
215
+
216
+ # Mutate
217
+ resource.instance_variable_get(:@data).spec.key = "updated"
218
+
219
+ # Stub reload after patch
220
+ ctl.stub_response("get", server_state(
221
+ metadata: { name: "app-config", namespace: "production", resourceVersion: "101" },
222
+ spec: { key: "updated" }
223
+ ))
224
+
225
+ result = resource.patch
226
+ result.should == true
227
+ end
228
+
229
+ # -------------------------------------------------------------------------
230
+ # Patch returns false when nothing changed
231
+ # -------------------------------------------------------------------------
232
+
233
+ it "patch_returns_false_when_clean" do
234
+ resource, ctl = build_resource(metadata: { name: "app-config", namespace: "default" }, spec: { key: "value" })
235
+
236
+ ctl.stub_response("get", server_state(
237
+ metadata: { name: "app-config", namespace: "default" }, spec: { key: "value" }
238
+ ))
239
+
240
+ result = resource.patch
241
+ result.should == false
242
+ end
243
+
244
+ # -------------------------------------------------------------------------
245
+ # Patch sends only the diff, not the full resource
246
+ # -------------------------------------------------------------------------
247
+
248
+ it "patch_sends_only_changed_fields" do
249
+ resource, ctl = build_resource(
250
+ metadata: { name: "my-config", namespace: "staging" },
251
+ spec: { db_host: "old-db.internal", db_port: "5432", cache_ttl: "300" }
252
+ )
253
+
254
+ # Mutate one field
255
+ resource.instance_variable_get(:@data).spec.db_host = "new-db.internal"
256
+
257
+ ctl.stub_response("get", server_state(
258
+ metadata: { name: "my-config", namespace: "staging" },
259
+ spec: { db_host: "new-db.internal", db_port: "5432", cache_ttl: "300" }
260
+ ))
261
+
262
+ resource.patch
263
+
264
+ # Find the patch command
265
+ patch_cmd = ctl.commands.find { |c| c.include?("patch") }
266
+
267
+ # Extract the JSON payload from the command (last arg after -p)
268
+ json_start = patch_cmd.index("-p ") + 3
269
+ payload = JSON.parse(patch_cmd[json_start..])
270
+
271
+ # The payload should contain the spec subtree but NOT metadata
272
+ payload.key?("spec").should.be.true
273
+ end
274
+
275
+ # -------------------------------------------------------------------------
276
+ # Reload resets dirty state from server response
277
+ # -------------------------------------------------------------------------
278
+
279
+ it "reload_resets_dirty_state" do
280
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
281
+
282
+ # Local mutation
283
+ resource.instance_variable_get(:@data).spec.key = "local-change"
284
+
285
+ # Server still has original
286
+ ctl.stub_response("get", server_state(
287
+ metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" }
288
+ ))
289
+
290
+ resource.reload
291
+
292
+ # After reload, local changes are gone and resource is clean
293
+ resource.to_h[:spec][:key].should == "v1"
294
+ end
295
+
296
+ it "reload_picks_up_server_side_changes" do
297
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
298
+
299
+ # Server has been mutated externally
300
+ ctl.stub_response("get", server_state(
301
+ metadata: { name: "my-config", namespace: "default", resourceVersion: "200" },
302
+ spec: { key: "server-updated" }
303
+ ))
304
+
305
+ resource.reload
306
+
307
+ # Resource reflects server state and is clean
308
+ resource.to_h[:spec][:key].should == "server-updated"
309
+ end
310
+
311
+ # -------------------------------------------------------------------------
312
+ # Apply snapshots after the server round-trip
313
+ # -------------------------------------------------------------------------
314
+
315
+ it "apply_snapshots_server_response" do
316
+ resource, ctl = build_resource(metadata: { name: "my-config" }, spec: { key: "v1" })
317
+
318
+ # Server adds metadata on apply
319
+ ctl.stub_response("get", server_state(
320
+ metadata: { name: "my-config", resourceVersion: "1", uid: "abc-123" },
321
+ spec: { key: "v1" }
322
+ ))
323
+
324
+ resource.apply
325
+
326
+ # The snapshot should include server-added fields, so mutating
327
+ # the original field shows the correct old value
328
+ resource.instance_variable_get(:@data).spec.key = "v2"
329
+ changes = resource.changes
330
+
331
+ # changes[:spec] is [old_hash, new_hash]
332
+ old_spec, new_spec = changes[:spec]
333
+ old_spec[:key].should == "v1"
334
+ end
335
+
336
+ # -------------------------------------------------------------------------
337
+ # Error cases: unpersisted resources
338
+ # -------------------------------------------------------------------------
339
+
340
+ it "patch_raises_on_unpersisted_resource" do
341
+ resource, _ctl = build_resource(spec: { key: "value" })
342
+
343
+ lambda { resource.patch }.should.raise Kube::CommandError
344
+ end
345
+
346
+ it "delete_raises_on_unpersisted_resource" do
347
+ resource, _ctl = build_resource(spec: { key: "value" })
348
+
349
+ lambda { resource.delete }.should.raise Kube::CommandError
350
+ end
351
+
352
+ it "reload_raises_on_unpersisted_resource" do
353
+ resource, _ctl = build_resource(spec: { key: "value" })
354
+
355
+ lambda { resource.reload }.should.raise Kube::CommandError
356
+ end
357
+
358
+ # -------------------------------------------------------------------------
359
+ # Nested mutation flows through patch_data correctly
360
+ # -------------------------------------------------------------------------
361
+
362
+ it "nested_mutation_produces_nested_patch" do
363
+ resource, ctl = build_resource(
364
+ metadata: { name: "my-config", namespace: "default", labels: { app: "web", tier: "frontend" } }
365
+ )
366
+
367
+ # Mutate only a nested field
368
+ resource.instance_variable_get(:@data).metadata.labels.tier = "backend"
369
+
370
+ patch = resource.patch_data
371
+ patch[:metadata][:labels][:tier].should == ["frontend", "backend"]
372
+ end
373
+
374
+ it "deeply_nested_no_change_produces_empty_patch" do
375
+ resource, _ctl = build_resource(
376
+ metadata: { name: "my-config", labels: { app: "web" } }
377
+ )
378
+
379
+ resource.patch_data.should == {}
380
+ end
381
+
382
+ # -------------------------------------------------------------------------
383
+ # Multiple mutations before patch coalesce into a single diff
384
+ # -------------------------------------------------------------------------
385
+
386
+ it "multiple_mutations_coalesce_in_single_patch" do
387
+ resource, ctl = build_resource(
388
+ metadata: { name: "my-config", namespace: "default" },
389
+ data: { host: "db-1", port: "5432", pool: "5" }
390
+ )
391
+
392
+ d = resource.instance_variable_get(:@data).data
393
+ d.host = "db-2"
394
+ d.port = "5433"
395
+ d.pool = "10"
396
+
397
+ ctl.stub_response("get", server_state(
398
+ metadata: { name: "my-config", namespace: "default" },
399
+ data: { host: "db-2", port: "5433", pool: "10" }
400
+ ))
401
+
402
+ resource.patch
403
+
404
+ # Exactly one patch command
405
+ patch_commands = ctl.commands.select { |c| c.include?("patch") }
406
+ patch_commands.size.should == 1
407
+ end
408
+
409
+ # -------------------------------------------------------------------------
410
+ # changes_applied mid-workflow resets the baseline
411
+ # -------------------------------------------------------------------------
412
+
413
+ it "changes_applied_resets_baseline_without_server_roundtrip" do
414
+ resource, _ctl = build_resource(metadata: { name: "my-config" }, spec: { key: "v1" })
415
+
416
+ resource.instance_variable_get(:@data).spec.key = "v2"
417
+
418
+ # Accept changes locally (no kubectl call)
419
+ resource.changes_applied
420
+
421
+ resource.changes.should == {}
422
+ end
423
+
424
+ it "changes_applied_then_patch_sends_only_subsequent_changes" do
425
+ resource, ctl = build_resource(
426
+ metadata: { name: "my-config", namespace: "default" },
427
+ data: { a: "1", b: "2", c: "3" }
428
+ )
429
+
430
+ # First wave of changes
431
+ resource.instance_variable_get(:@data).data.a = "changed-a"
432
+ resource.changes_applied
433
+
434
+ # Second wave — only b changes from the new baseline
435
+ resource.instance_variable_get(:@data).data.b = "changed-b"
436
+
437
+ ctl.stub_response("get", server_state(
438
+ metadata: { name: "my-config", namespace: "default" },
439
+ data: { a: "changed-a", b: "changed-b", c: "3" }
440
+ ))
441
+
442
+ resource.patch
443
+
444
+ patch_cmd = ctl.commands.find { |c| c.include?("patch") }
445
+ payload = JSON.parse(patch_cmd.split("-p ").last)
446
+
447
+ # Only b should be in the patch, not a (already accepted via changes_applied)
448
+ payload["data"]["b"].should == ["2", "changed-b"]
449
+ end
450
+
451
+ # -------------------------------------------------------------------------
452
+ # Dynamic attr_changed? tracks through full lifecycle
453
+ # -------------------------------------------------------------------------
454
+
455
+ it "attr_changed_through_apply_mutate_patch_cycle" do
456
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
457
+
458
+ ctl.stub_response("get", server_state(
459
+ metadata: { name: "my-config", namespace: "default" },
460
+ spec: { key: "v1" }
461
+ ))
462
+
463
+ resource.apply
464
+
465
+ resource.instance_variable_get(:@data).spec.key = "v2"
466
+
467
+ ctl.stub_response("get", server_state(
468
+ metadata: { name: "my-config", namespace: "default" },
469
+ spec: { key: "v2" }
470
+ ))
471
+
472
+ resource.patch
473
+
474
+ resource.spec_changed?.should.be.false
475
+ end
476
+
477
+ it "respond_to_for_dynamic_changed_predicates" do
478
+ resource, _ctl = build_resource(metadata: { name: "test" })
479
+
480
+ resource.should.respond_to :metadata_changed?
481
+ end
482
+
483
+ # -------------------------------------------------------------------------
484
+ # Snapshot isolation: reload doesn't leak into captured references
485
+ # -------------------------------------------------------------------------
486
+
487
+ it "reload_does_not_corrupt_previously_captured_changes" do
488
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
489
+
490
+ resource.instance_variable_get(:@data).spec.key = "v2"
491
+
492
+ # Capture changes before reload
493
+ changes_before = resource.changes
494
+ patch_before = resource.patch_data
495
+
496
+ # Reload with different server state
497
+ ctl.stub_response("get", server_state(
498
+ metadata: { name: "my-config", namespace: "default" },
499
+ spec: { key: "v3-from-server" }
500
+ ))
501
+
502
+ resource.reload
503
+
504
+ # Previously captured hashes should be unaffected
505
+ extract_nested_value(changes_before, :spec, :key, 1).should == "v2"
506
+ end
507
+
508
+ it "snapshot_isolation_across_multiple_changes_applied" do
509
+ resource, _ctl = build_resource(metadata: { name: "test" }, data: { counter: "1" })
510
+
511
+ resource.instance_variable_get(:@data).data.counter = "2"
512
+ snapshot_1_changes = resource.changes
513
+
514
+ resource.changes_applied
515
+
516
+ resource.instance_variable_get(:@data).data.counter = "3"
517
+ snapshot_2_changes = resource.changes
518
+
519
+ # Each snapshot's changes should be independent
520
+ extract_nested_value(snapshot_1_changes, :data, :counter, 0).should == "1"
521
+ end
522
+
523
+ # -------------------------------------------------------------------------
524
+ # Edge case: resource with no initial spec data
525
+ # -------------------------------------------------------------------------
526
+
527
+ it "empty_resource_tracks_all_additions" do
528
+ resource, _ctl = build_resource(metadata: { name: "empty-config" })
529
+
530
+ resource.instance_variable_get(:@data).spec.key = "added"
531
+
532
+ resource.changed.should.include :spec
533
+ end
534
+
535
+ # -------------------------------------------------------------------------
536
+ # Edge case: patch type parameter is forwarded
537
+ # -------------------------------------------------------------------------
538
+
539
+ it "patch_forwards_type_parameter" do
540
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
541
+
542
+ resource.instance_variable_get(:@data).spec.key = "v2"
543
+
544
+ ctl.stub_response("get", server_state(
545
+ metadata: { name: "my-config", namespace: "default" },
546
+ spec: { key: "v2" }
547
+ ))
548
+
549
+ resource.patch(type: "merge")
550
+
551
+ patch_cmd = ctl.commands.find { |c| c.include?("patch") }
552
+ patch_cmd.should.include "--type merge"
553
+ end
554
+
555
+ it "patch_defaults_to_strategic_type" do
556
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" }, spec: { key: "v1" })
557
+
558
+ resource.instance_variable_get(:@data).spec.key = "v2"
559
+
560
+ ctl.stub_response("get", server_state(
561
+ metadata: { name: "my-config", namespace: "default" },
562
+ spec: { key: "v2" }
563
+ ))
564
+
565
+ resource.patch
566
+
567
+ patch_cmd = ctl.commands.find { |c| c.include?("patch") }
568
+ patch_cmd.should.include "--type strategic"
569
+ end
570
+
571
+ # -------------------------------------------------------------------------
572
+ # Edge case: namespace flags are included correctly
573
+ # -------------------------------------------------------------------------
574
+
575
+ it "patch_includes_namespace_flags" do
576
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "kube-system" }, spec: { key: "v1" })
577
+
578
+ resource.instance_variable_get(:@data).spec.key = "v2"
579
+
580
+ ctl.stub_response("get", server_state(
581
+ metadata: { name: "my-config", namespace: "kube-system" },
582
+ spec: { key: "v2" }
583
+ ))
584
+
585
+ resource.patch
586
+
587
+ patch_cmd = ctl.commands.find { |c| c.include?("patch") }
588
+ patch_cmd.should.include "--namespace kube-system"
589
+ end
590
+
591
+ it "reload_includes_namespace_flags" do
592
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "monitoring" }, spec: { key: "v1" })
593
+
594
+ ctl.stub_response("get", server_state(
595
+ metadata: { name: "my-config", namespace: "monitoring" },
596
+ spec: { key: "v1" }
597
+ ))
598
+
599
+ resource.reload
600
+
601
+ get_cmd = ctl.commands.find { |c| c.include?("get") }
602
+ get_cmd.should.include "--namespace monitoring"
603
+ end
604
+
605
+ # -------------------------------------------------------------------------
606
+ # Edge case: delete on persisted resource issues command
607
+ # -------------------------------------------------------------------------
608
+
609
+ it "delete_issues_kubectl_delete" do
610
+ resource, ctl = build_resource(metadata: { name: "my-config", namespace: "default" })
611
+
612
+ result = resource.delete
613
+
614
+ delete_cmd = ctl.commands.find { |c| c.include?("delete") }
615
+ delete_cmd.should.include "my-config"
616
+ end
617
+
618
+ # -------------------------------------------------------------------------
619
+ # Regression: the original bug — build_changes used `result` instead of `hash`
620
+ # -------------------------------------------------------------------------
621
+
622
+ it "changes_does_not_raise_name_error" do
623
+ resource, _ctl = build_resource(metadata: { name: "my-config" }, spec: { key: "v1" })
624
+
625
+ resource.instance_variable_get(:@data).spec.key = "v2"
626
+
627
+ # This would raise NameError with the original bug
628
+ changes = resource.changes
629
+
630
+ changes.should.be.kind_of Hash
631
+ end
632
+
633
+ private
634
+
635
+ # Navigate into nested change structures.
636
+ # changes[:spec] could be [old_hash, new_hash] or a nested diff hash.
637
+ def extract_nested_value(hash, top_key, nested_key, index)
638
+ val = hash[top_key]
639
+ case val
640
+ when Array
641
+ # [old_hash, new_hash]
642
+ val[index].is_a?(Hash) ? val[index][nested_key] : val[index]
643
+ when Hash
644
+ # nested diff: { key: [old, new] }
645
+ val[nested_key].is_a?(Array) ? val[nested_key][index] : val[nested_key]
646
+ end
647
+ end
648
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kube
4
4
  module Cluster
5
- VERSION = "0.3.7"
5
+ VERSION = "0.3.9"
6
6
  end
7
7
  end
data/lib/kube/cluster.rb CHANGED
@@ -6,8 +6,8 @@ require_relative "cluster/version"
6
6
  require_relative "cluster/connection"
7
7
  require_relative "cluster/instance"
8
8
  require_relative "cluster/resource"
9
- require_relative "cluster/manifest"
10
9
  require_relative "cluster/middleware"
10
+ require_relative "cluster/manifest"
11
11
  require 'kube/ctl'
12
12
  require_relative 'helm/repo'
13
13
 
@@ -44,3 +44,9 @@ module Kube
44
44
  end
45
45
  end
46
46
  end
47
+
48
+ test do
49
+ it "version" do
50
+ Kube::Cluster::VERSION.should.not.be.nil
51
+ end
52
+ end