kube_cluster 0.20.1 → 1.0.0

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