legion-crypt 1.5.37 → 1.5.38

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9037b8b6b87bdff6a7e839f7223a825984786e5be4a323c20ce2673eccf3e93a
4
- data.tar.gz: 1548b784a608b1c9b71794d937bb5bd0f0bfcd74e8337b31132bc817909926d5
3
+ metadata.gz: 1d7d1cbd214ebc5bc7f8d109b958e3d307e3a4faaa81fdb2c47b527670ab720f
4
+ data.tar.gz: 4ea67b85552ddae267d4945be1586e7248a30b3dc38dfa2f280ae59311524614
5
5
  SHA512:
6
- metadata.gz: da7ae3fb308f8ccd82687707d0e28954d208dfe4f874d37287df46c05d7e7cf3bb3e3b22e9793fd4fdcf9cf8c71bd07d0c331b6a579dc3dc39e15ec31ce24865
7
- data.tar.gz: 20a76900d62030ace417f135ba1666d05cd8304bee62f4bac1fa1cb7cd4451a06e634e29220ea0c4b105e6b30e5fdb3187d8a7db5bef1c75a1e33bee258bb4fa
6
+ metadata.gz: 2af577b9a0c3c7e464e79f11bbd13e044d9e3e76520c2b4ee6ad27949135c0e873aa8aadfca33d67324061dd55d1f7f5f21ceb1715aa9a7d1d0f1fac77d74852
7
+ data.tar.gz: 4226e44bc08ebdc1928ce91630125db8e9a3c674e5ce64cf47ed7375706876388f9aeb3e93816402e41b10c0961bb7f19bd57dc6f64e826289333b27f558c36e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Legion::Crypt
2
2
 
3
+ ## [1.5.38] - 2026-10-31
4
+
5
+ ### Fixed
6
+ - Replace lease-backed settings bindings per destination and publish resolved values atomically, preserving other destinations and restoring assignments and binding metadata on failed publication or credential handoff (LegionIO/legion-settings#21).
7
+ - Track each binding's last published value through rotation, retain standalone settings roots, and follow the active loader when its settings root is rebuilt.
8
+
3
9
  ## [1.5.37] - 2026-10-30
4
10
 
5
11
  ### Changed
data/README.md CHANGED
@@ -185,6 +185,12 @@ Lease names are stable across environments. The actual Vault paths are deploymen
185
185
 
186
186
  `LeaseManager#register_ref(name, key, path)` accepts typed destination paths: Symbols for existing Hash keys and nonnegative Integers for existing Array indexes. A lease-data key can target multiple destinations; duplicate registrations are ignored. `push_to_settings` updates valid destinations, including nested arrays, and returns the number of successful writes. Invalid, missing, frozen, or out-of-bounds destinations are not modified. Transactional credential handoff requires every referenced destination to be writable before changing any of them.
187
187
 
188
+ Starting with Crypt **1.5.38**, Settings **1.4.4** uses `LeaseManager#update_settings_refs(settings:, publish: nil)` to replace destination bindings and publish resolved settings atomically. Preparation runs under the lease-state mutex with a memory-only `reader.call(name:, key:)`; external secret reads must finish beforehand. The proposal contains typed `paths` to replace, `assignments` (`container`, `key`, `value`), and selected `references` (`name`, `key`, `path`, mutable `binding` containing `value`). Other destinations remain registered, and ambiguous writers are rejected.
189
+
190
+ `publish.call(proposal)` is restricted to in-memory pointer/metadata publication, not consumer callbacks or manager calls. Failed preparation, validation, or publication preserves prior assignments and bindings. Publication waits for an in-flight credential handoff to commit or roll back; successful rotations update each binding's tracked value, and failed handoffs restore both the target and its separately tracked previous value. Shutdown and reset clear bindings with the registry.
191
+
192
+ Release Crypt before Settings. Older `register_ref` callers remain supported, but source priority and reload provenance are handled by Settings, not by Crypt.
193
+
188
194
  ## Multi-Cluster Vault
189
195
 
190
196
  `VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `Legion::Crypt::VaultClient` instance.
@@ -21,6 +21,8 @@ module Legion
21
21
  @owned_leases = {}
22
22
  @pending = {}
23
23
  @refs = {}
24
+ @ref_bindings = {}
25
+ @installations = {}.compare_by_identity
24
26
  @lease_locks = {}
25
27
  @state_mutex = Mutex.new
26
28
  @wake = ConditionVariable.new
@@ -76,13 +78,43 @@ module Legion
76
78
  def register_ref(name, key, path, generation: @generation)
77
79
  return false unless path.is_a?(Array) && !path.empty?
78
80
 
81
+ name = name.to_sym
82
+ key = key.to_sym
79
83
  with_current_state(generation: generation) do
80
- @refs[name.to_sym] ||= {}
81
- destinations = (@refs[name.to_sym][key.to_sym] ||= [])
84
+ @refs.delete_if do |registered_name, keys|
85
+ keys.delete_if do |registered_key, destinations|
86
+ @ref_bindings.delete(path) if !(registered_name == name && registered_key == key) && destinations.delete(path)
87
+ destinations.empty?
88
+ end
89
+ keys.empty?
90
+ end
91
+ @refs[name] ||= {}
92
+ destinations = (@refs[name][key] ||= [])
82
93
  destinations << path.dup.freeze unless destinations.include?(path)
83
94
  end
84
95
  end
85
96
 
97
+ # Preparation and publication must only perform in-memory work, without calling manager methods.
98
+ def update_settings_refs(settings:, publish: nil)
99
+ raise ArgumentError, 'Settings reference preparation requires a block' unless block_given?
100
+
101
+ @state_mutex.synchronize do
102
+ raise 'Cannot publish settings from an in-flight lease reconnect' if @installations.value?(Thread.current)
103
+
104
+ generation = @generation
105
+ @wake.wait(@state_mutex) while !@installations.empty? && lifecycle_current?(generation: generation)
106
+ raise 'Cannot update settings references in an inactive lifecycle' unless lifecycle_current?(generation: generation)
107
+
108
+ reader = lambda do |name:, key:|
109
+ data = cached_lease_data(name: name.to_sym)
110
+ data && (data.key?(key.to_sym) ? data[key.to_sym] : data[key.to_s])
111
+ end
112
+ proposal = yield reader
113
+ apply_settings_proposal(settings: settings, proposal: proposal, publish: publish)
114
+ proposal
115
+ end
116
+ end
117
+
86
118
  def push_to_settings(name, generation: @generation, active: nil)
87
119
  count = with_current_state(generation: generation, active: active) do
88
120
  refs = @refs[name.to_sym]
@@ -192,6 +224,7 @@ module Legion
192
224
  @active_leases.clear
193
225
  @lease_cache.clear
194
226
  @refs.clear
227
+ @ref_bindings.clear
195
228
  @pending.clear
196
229
  @vault_client = nil
197
230
  @owned_leases.each_value { |entry| entry[:retired] = true }
@@ -209,6 +242,7 @@ module Legion
209
242
  @owned_leases.clear
210
243
  @pending.clear
211
244
  @refs.clear
245
+ @ref_bindings.clear
212
246
  @vault_client = nil
213
247
  @stopped = false
214
248
  end
@@ -216,6 +250,113 @@ module Legion
216
250
 
217
251
  private
218
252
 
253
+ def apply_settings_proposal(settings:, proposal:, publish:)
254
+ validate_settings_proposal!(settings: settings, proposal: proposal)
255
+ references, bindings = settings_reference_registry(settings: settings, proposal: proposal)
256
+ assignments = proposal[:assignments].map do |assignment|
257
+ [assignment[:container], assignment[:key], assignment[:container][assignment[:key]], assignment[:value]]
258
+ end
259
+ binding_values = proposal[:references].map { |reference| [reference[:binding], reference[:binding][:value]] }
260
+ previous_refs = @refs
261
+ previous_bindings = @ref_bindings
262
+ begin
263
+ assignments.each { |container, key, _previous, value| container[key] = value }
264
+ @refs = references
265
+ @ref_bindings = bindings
266
+ publish_settings_proposal(settings: settings, proposal: proposal, publish: publish)
267
+ rescue StandardError
268
+ @refs = previous_refs
269
+ @ref_bindings = previous_bindings
270
+ assignments.reverse_each { |container, key, previous, _value| container[key] = previous }
271
+ binding_values.each { |binding, previous| binding[:value] = previous }
272
+ raise
273
+ end
274
+ end
275
+
276
+ def settings_reference_registry(settings:, proposal:)
277
+ paths = proposal[:paths]
278
+ references = @refs.each_with_object({}) do |(name, keys), result|
279
+ remaining = keys.transform_values { |destinations| destinations.reject { |path| paths.include?(path) } }
280
+ remaining.reject! { |_key, destinations| destinations.empty? }
281
+ result[name] = remaining unless remaining.empty?
282
+ end
283
+ bindings = @ref_bindings.except(*paths)
284
+ proposal[:references].each do |reference|
285
+ name = reference[:name].to_sym
286
+ key = reference[:key].to_sym
287
+ path = reference[:path].dup.freeze
288
+ references[name] ||= {}
289
+ (references[name][key] ||= []) << path
290
+ bindings[path] = { settings: settings, binding: reference[:binding] }
291
+ end
292
+ [references, bindings]
293
+ end
294
+
295
+ def publish_settings_proposal(settings:, proposal:, publish:)
296
+ proposal[:references].each do |reference|
297
+ target, key = setting_target(path: reference[:path], settings: settings)
298
+ raise ArgumentError, 'Settings assignment removed a reference destination' unless target
299
+
300
+ reference[:binding][:value] = target[key]
301
+ end
302
+ publish&.call(proposal)
303
+ return unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader&.settings.equal?(settings)
304
+
305
+ proposal[:references].each { |reference| @ref_bindings[reference[:path]][:settings] = nil }
306
+ end
307
+
308
+ def validate_settings_proposal!(settings:, proposal:)
309
+ raise ArgumentError, 'Settings root must be a Hash or Array' unless settings.is_a?(Hash) || settings.is_a?(Array)
310
+ unless proposal.is_a?(Hash) && %i[paths assignments references].all? { |key| proposal[key].is_a?(Array) }
311
+ raise ArgumentError, 'Invalid settings reference proposal'
312
+ end
313
+
314
+ proposal[:paths].each do |path|
315
+ unless path.is_a?(Array) && !path.empty? && path.all? { |part| part.is_a?(Symbol) || (part.is_a?(Integer) && part >= 0) }
316
+ raise ArgumentError, 'Invalid typed settings path'
317
+ end
318
+ end
319
+ validate_settings_assignments!(assignments: proposal[:assignments])
320
+ validate_settings_references!(settings: settings, paths: proposal[:paths], references: proposal[:references])
321
+ end
322
+
323
+ def validate_settings_assignments!(assignments:)
324
+ destinations = {}
325
+ assignments.each do |assignment|
326
+ raise ArgumentError, 'Invalid settings assignment' unless assignment.is_a?(Hash) && assignment.key?(:value)
327
+
328
+ container = assignment[:container]
329
+ key = assignment[:key]
330
+ raise ArgumentError, 'Unwritable settings assignment' unless valid_setting_key?(container: container, key: key) && !container.frozen?
331
+ raise ArgumentError, 'Duplicate settings assignment' if destinations[[container.object_id, key]]
332
+
333
+ destinations[[container.object_id, key]] = true
334
+ end
335
+ end
336
+
337
+ def validate_settings_references!(settings:, paths:, references:)
338
+ destinations = {}
339
+ binding_ids = {}.compare_by_identity
340
+ references.each do |reference|
341
+ unless reference.is_a?(Hash) && paths.include?(reference[:path]) &&
342
+ [reference[:name], reference[:key]].all? { |value| value.is_a?(String) || value.is_a?(Symbol) }
343
+ raise ArgumentError, 'Invalid settings reference'
344
+ end
345
+
346
+ target, key = setting_target(path: reference[:path], settings: settings)
347
+ raise ArgumentError, 'Unwritable settings reference destination' unless target
348
+ raise ArgumentError, 'Ambiguous settings reference destination' if destinations[[target.object_id, key]]
349
+
350
+ binding = reference[:binding]
351
+ unless binding.is_a?(Hash) && !binding.frozen? && binding.key?(:value) && !binding_ids[binding]
352
+ raise ArgumentError, 'Settings references require distinct mutable binding records'
353
+ end
354
+
355
+ destinations[[target.object_id, key]] = true
356
+ binding_ids[binding] = true
357
+ end
358
+ end
359
+
219
360
  def register_at_exit_hook
220
361
  return if @at_exit_registered
221
362
 
@@ -310,7 +451,14 @@ module Legion
310
451
  end
311
452
  committed
312
453
  ensure
313
- abort_install(handoff: handoff) if handoff && !committed
454
+ begin
455
+ abort_install(handoff: handoff) if handoff && !committed
456
+ ensure
457
+ @state_mutex.synchronize do
458
+ @installations.delete(candidate)
459
+ @wake.broadcast
460
+ end
461
+ end
314
462
  retire_unused_leases(name: name, deadline: context[:deadline])
315
463
  end
316
464
 
@@ -330,10 +478,15 @@ module Legion
330
478
  snapshots = setting_snapshots(name: name, data: data)
331
479
  transaction = { previous: previous, data: @lease_cache[name], settings: snapshots, candidate: handoff[:candidate] }
332
480
  handoff[:transaction] = transaction
333
- snapshots.each { |target, key, _old, value| target[key] = value }
481
+ @installations[handoff[:candidate]] = Thread.current
482
+ snapshots.each do |target, key, old, value, binding|
483
+ target[key] = value unless old == value
484
+ binding[:value] = value if binding
485
+ end
334
486
  @lease_cache[name] = data
335
487
  @active_leases[name] = handoff[:candidate]
336
- handoff[:reconnect] = context[:reconnect] || (previous && transaction[:data] != data) || !snapshots.empty?
488
+ handoff[:reconnect] = context[:reconnect] || (previous && transaction[:data] != data) ||
489
+ snapshots.any? { |_target, _key, old, value| old != value }
337
490
  true
338
491
  end
339
492
  end
@@ -376,11 +529,15 @@ module Legion
376
529
 
377
530
  value = data.key?(key) ? data[key] : data[key.to_s]
378
531
  paths.filter_map do |path|
379
- target, segment = setting_target(path: path)
532
+ record = @ref_bindings[path]
533
+ target, segment = setting_target(path: path, settings: record&.dig(:settings))
380
534
  raise "Lease #{name} has an unwritable settings destination #{path.inspect}" unless target
381
- next if target[segment] == value
382
535
 
383
- [target, segment, target[segment], value]
536
+ binding = record&.dig(:binding)
537
+ raise "Lease #{name} has an immutable settings binding" if binding&.frozen?
538
+ next if target[segment] == value && (!binding || binding[:value] == value)
539
+
540
+ [target, segment, target[segment], value, binding, binding&.dig(:value)]
384
541
  end
385
542
  end
386
543
  end
@@ -392,8 +549,10 @@ module Legion
392
549
  current = @active_leases[name]
393
550
  next false unless current.equal?(transaction[:candidate]) || current.equal?(transaction[:previous])
394
551
 
395
- transaction[:settings].each do |target, key, previous, candidate|
552
+ transaction[:settings].each do |snapshot|
553
+ target, key, previous, candidate, binding, previous_binding = snapshot
396
554
  target[key] = previous if target[key] == candidate
555
+ binding[:value] = previous_binding if binding && binding[:value] == candidate
397
556
  end
398
557
  if transaction[:previous]
399
558
  @active_leases[name] = transaction[:previous]
@@ -585,28 +744,34 @@ module Legion
585
744
  lease[:expires_at] - Time.now - (ttl * 0.5)
586
745
  end
587
746
 
588
- def setting_target(path:, **)
747
+ def setting_target(path:, settings: nil, **)
589
748
  return unless path.is_a?(Array) && !path.empty?
590
749
 
591
- target = Legion::Settings.get.settings
750
+ target = settings
751
+ target ||= Legion::Settings.loader&.settings if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader)
592
752
  path.each_with_index do |segment, index|
593
- valid =
594
- case target
595
- when Hash then segment.is_a?(Symbol) && target.key?(segment)
596
- when Array then segment.is_a?(Integer) && segment >= 0 && segment < target.length
597
- end
598
- return nil unless valid
753
+ return nil unless valid_setting_key?(container: target, key: segment)
599
754
  return target.frozen? ? nil : [target, segment] if index == path.length - 1
600
755
 
601
756
  target = target[segment]
602
757
  end
603
758
  end
604
759
 
760
+ def valid_setting_key?(container:, key:)
761
+ case container
762
+ when Hash then key.is_a?(Symbol) && container.key?(key)
763
+ when Array then key.is_a?(Integer) && key >= 0 && key < container.length
764
+ end
765
+ end
766
+
605
767
  def write_setting(path, value)
606
- target, key = setting_target(path: path)
607
- return false unless target
768
+ record = @ref_bindings[path]
769
+ target, key = setting_target(path: path, settings: record&.dig(:settings))
770
+ binding = record&.dig(:binding)
771
+ return false unless target && !binding&.frozen?
608
772
 
609
773
  target[key] = value
774
+ binding[:value] = value if binding
610
775
  true
611
776
  rescue StandardError => e
612
777
  handle_exception(e, level: :warn, operation: 'crypt.lease_manager.write_setting', path: path.inspect)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.5.37'
5
+ VERSION = '1.5.38'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-crypt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.37
4
+ version: 1.5.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity