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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +6 -0
- data/lib/legion/crypt/lease_manager.rb +184 -19
- data/lib/legion/crypt/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d7d1cbd214ebc5bc7f8d109b958e3d307e3a4faaa81fdb2c47b527670ab720f
|
|
4
|
+
data.tar.gz: 4ea67b85552ddae267d4945be1586e7248a30b3dc38dfa2f280ae59311524614
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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)
|
data/lib/legion/crypt/version.rb
CHANGED