legion-settings 1.4.0 → 1.4.4

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: baf40211abee6a48e5863077ce8e31fa416afbad9f003933e7edcfca4b8a72ba
4
- data.tar.gz: f8faf5fb5eadead569c9f222a70c58e5ac11bc923dc281bd587f3f2704125cd0
3
+ metadata.gz: 5b4ca4881211bedb39799998f075d8539d5ebfdf79178f2d6c61e9ee1ebd28db
4
+ data.tar.gz: fcf3c214190e2263cb67e4d17c5f8ddaceb478e5fed16a9a8021ed2722e4d160
5
5
  SHA512:
6
- metadata.gz: 7dbe223a77152d41812ec94bd98457db0570dae8bdea879f58b4ec052fca03eed46d1364617d4affb73d4cd7bc0364e6f6074c5a0fe5d8c433776e900d354417
7
- data.tar.gz: b362c5d3a541c5afce645b47656ef1a427b65d58500bd5100e14717451dacaa8024823a188fd97f2475cb2ab1e176912c30ed7ba0496f0f512dd79a593228401
6
+ metadata.gz: f3f2a51d7a7633bf404b5f965c374357abc4a70d5c9e8d69ebd1bee22dfd4b3c878a4ee3af9870d1a4d42fe471a973db3b9c765dca9fc835f105b8c6ecd8ce42
7
+ data.tar.gz: 34a30b2ec19e8c310c89a5c59b1d2b87ad01fddd09d5948738c3092cd6c5d77d86384a25aa1884bbe44fc27db288e929c1db863d2ef46b126b7b4d045e5c124b
@@ -9,12 +9,16 @@ on:
9
9
  jobs:
10
10
  ci:
11
11
  uses: LegionIO/.github/.github/workflows/ci.yml@main
12
+ with:
13
+ companion-gems: ${{ github.event_name == 'pull_request' && '{"legion-crypt":{"repository":"LegionIO/legion-crypt","ref":"f22702285d9ad9d80ddab115cf93568583bd7211"}}' || '{}' }}
12
14
 
13
15
  lint:
14
16
  uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
15
17
 
16
18
  security:
17
19
  uses: LegionIO/.github/.github/workflows/security-scan.yml@main
20
+ with:
21
+ companion-gems: ${{ github.event_name == 'pull_request' && '{"legion-crypt":{"repository":"LegionIO/legion-crypt","ref":"f22702285d9ad9d80ddab115cf93568583bd7211"}}' || '{}' }}
18
22
 
19
23
  version-changelog:
20
24
  uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
data/.gitignore CHANGED
@@ -14,3 +14,4 @@
14
14
  # rspec failure tracking
15
15
  .rspec_status
16
16
  legionio.key
17
+ /.ci-dependencies/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Legion::Settings Changelog
2
2
 
3
+ ## [1.4.4] - 2026-10-31
4
+
5
+ ### Fixed
6
+ - Lease rotation follows only the winning secret source, with typed paths through nested hashes and arrays.
7
+ - Preserve source declarations across repeated resolution and rotations; retire bindings when sources change or destinations disappear.
8
+ - Preserve lease declarations inside module-default arrays when normalizing the live settings tree, so reload can bind them again.
9
+ - Publish hot-reloaded settings and lease references together, including equal-value source changes. Invalid files or unresolved secrets leave the active loader and bindings intact.
10
+ - Keep Vault reads outside lease publication locks and scope read caches to each resolution.
11
+ - Remove ambiguous backtracking from Vault and lease URI parsing while preserving existing delimiter semantics.
12
+ - Accept extra keyword options in `Loader#load_file` while preserving the explicit `strict:` behavior.
13
+
14
+ ### Documentation
15
+ - Clarify the process-local, shared in-memory settings model and its persistence, synchronization, and component-ownership boundaries.
16
+
17
+ ### Compatibility
18
+ - Lease resolution requires `legion-crypt >= 1.5.38`; environment and literal-only settings remain usable without Crypt.
19
+
20
+ ## [1.4.3] - 2026-10-30
21
+
22
+ ### Fixed
23
+ - Library registration idempotency now checks the active loader, allowing defaults to be registered after loader replacement without discarding user overrides.
24
+
25
+ ## [1.4.2] - 2026-08-14
26
+
27
+ ### Fixed
28
+ - `Legion::Settings::Helper#settings` now resolves two-segment (nested) extensions to their nested settings path instead of a flat key. `lex-llm-vllm` (`Legion::Extensions::Llm::Vllm::Runners::DiscoveryRefresh`) resolves to `Legion::Settings[:extensions][:llm][:vllm]` — not the flat `:llm_vllm` key — and `lex-identity-entra` (`Legion::Extensions::Identity::Entra::Delegated::Runners::Login`) resolves to `[:extensions][:identity][:entra]`. All `lex-<a>-<b>` nested extensions (lex-llm-*, lex-identity-*, lex-agentic-*, etc.) were reading the wrong (empty) settings path in production; single-segment `lex-<name>` extensions were unaffected.
29
+ - Resolution is now segment-based: an explicit `segments` method (LegionIO's `Helpers::Base` mixin) is preferred; otherwise segments are derived from the class namespace, stopping at boundary words (Actor/Actors/Runners/Helpers/Transport/Data), with the gem boundary verified against the `Legion::Settings::Extensions` registry. Sub-module runners resolve to their parent gem's settings.
30
+
31
+ ### Added
32
+ - Regression spec in `spec/legion/settings/helper_spec.rb` covering nested two-segment runner resolution (lex-llm-vllm, lex-identity-entra) that fails on the 1.4.0 flat-key helper.
33
+
34
+ ### Changed
35
+ - `Settings#[]` and `Settings#dig` read the settings hash directly with string→symbol key fallback instead of rebuilding an indifferent-access hash tree on every read (`indifferent_access!` now only runs on explicit `to_hash` calls) — removes the boot-time O(n) rebuild per read.
36
+ - Overlay skips resolution when no overlay is active (fast path).
37
+ - Consolidated four duplicate deep-merge implementations into a shared `Legion::Settings::DeepMerge` module that preserves `Concurrent::Hash` type through merges.
38
+ - FQDN is lazy-resolved on first access instead of blocking at init; schema validation is deferred to `validate!` instead of running on every merge.
39
+
3
40
  ## [1.4.0] - 2026-04-29
4
41
 
5
42
  ### Added
data/Gemfile CHANGED
@@ -4,6 +4,9 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
  group :test do
7
+ crypt_paths = [File.join(__dir__, '.ci-dependencies/legion-crypt'), File.expand_path('../legion-crypt', __dir__)]
8
+ crypt_path = crypt_paths.find { |path| File.directory?(path) && File.file?(File.join(path, 'legion-crypt.gemspec')) }
9
+ gem 'legion-crypt', '>= 1.5.38', **(crypt_path ? { path: crypt_path } : {})
7
10
  gem 'legion-logging'
8
11
  gem 'logger'
9
12
  gem 'rake'
data/README.md CHANGED
@@ -1,8 +1,19 @@
1
1
  # legion-settings
2
2
 
3
- Configuration management module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Loads settings from JSON files, directories, and environment variables. Provides a unified `Legion::Settings[:key]` accessor used by all other Legion gems.
3
+ The process-local, in-memory settings store for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Loads settings from JSON files, directories, and environment variables into a shared settings tree, exposed through `Legion::Settings[:key]`.
4
4
 
5
- **Version**: 1.3.27
5
+ **Version**: 1.4.4
6
+
7
+ ## Process-Local Settings Model
8
+
9
+ Think of `legion-settings` as **one big, process-local `Concurrent::Hash`-style object**: the settings tree for that running Ruby process.
10
+
11
+ - **Shared within the process.** Gems, extensions, actors, and worker threads use the same base settings tree through `Legion::Settings`, rather than maintaining separate copies of the application's configuration.
12
+ - **Local to that process.** Another process has its own settings object, even on the same host or when loading the same files. Changing this process's settings does not update another process or node.
13
+ - **Generic storage, component-owned meaning.** Each library owns its namespace, defaults, and behavior. Settings supplies common loading, merging, access, and reload mechanics; it does not own each component's execution policy.
14
+ - **An in-memory object, not a persistence or synchronization service.** Files, environment variables, defaults, and resolved secrets populate the tree. Runtime changes do not automatically write back to those sources or broadcast to other processes.
15
+
16
+ Request overlays provide scoped overrides without replacing the shared base tree. The `Concurrent::Hash`-style model does not mean every nested value is itself a concurrent hash or that an arbitrary sequence of reads and writes is atomic; callers must coordinate compound mutations.
6
17
 
7
18
  ## Installation
8
19
 
@@ -48,6 +59,8 @@ Each Legion module registers its own defaults via `merge_settings` during startu
48
59
 
49
60
  `Legion::Settings.reload!` re-reads the config files that were previously loaded, reapplies module defaults and the nearest `.legionio.env`, re-resolves secret references, and returns a hash describing the changed keys.
50
61
 
62
+ Reload publishes the new loader and its lease bindings together. A source change is committed even when its resolved value is unchanged, so replacing a lease URI with the same literal bytes still stops rotation. Malformed files or unresolved secrets raise and leave the active loader and bindings intact.
63
+
51
64
  ```ruby
52
65
  changes = Legion::Settings.reload!
53
66
 
@@ -57,7 +70,7 @@ changes
57
70
  # }
58
71
  ```
59
72
 
60
- Callbacks run only when changes are detected:
73
+ Callbacks run after publication, outside the reload and lease locks, and only when resolved values change:
61
74
 
62
75
  ```ruby
63
76
  Legion::Settings.on_reload do |changes|
@@ -113,9 +126,17 @@ Array values act as fallback chains — the first non-nil result wins:
113
126
 
114
127
  Call `Legion::Settings.resolve_secrets!` to resolve all URIs in-place. In the LegionIO boot sequence this is called automatically after `Legion::Crypt.start`. The `env://` scheme works even when Vault is not connected.
115
128
 
129
+ Lease integration requires **legion-crypt 1.5.38 or newer** with **legion-settings 1.4.4 or newer**. Release Crypt first. Neither gem gains a runtime dependency on the other; environment and literal-only resolution work without Crypt.
130
+
131
+ Only the winning lease in a fallback chain can rotate its destination. A winning environment, Vault, or literal value—including `false`—does not subscribe unused leases. Nested hashes and arrays use typed paths, so array indexes and keys containing dots are preserved.
132
+
133
+ Repeated resolution reevaluates the retained source declarations, including after rotation. Editing a URI or changing a resolved value replaces its source; removing a destination retires its lease binding. A fresh reload treats file contents as new declarations rather than guessing provenance from matching secret values. Vault read caches are local to each resolution, and Vault reads finish before lease publication begins.
134
+
135
+ An unresolved one-shot reference is logged and left unchanged; strict reload rejects unresolved references instead of replacing live settings.
136
+
116
137
  ```ruby
117
138
  Legion::Settings.resolve_secrets!
118
- # All vault://, env://, and lease:// references are now replaced with their resolved values
139
+ # Resolvable vault://, env://, and lease:// references are replaced with their values
119
140
  ```
120
141
 
121
142
  ### Schema Validation
@@ -132,6 +153,17 @@ Legion::Settings.validate! # raises ValidationError if any settings are invalid
132
153
  # validate! will warn through the configured logger instead of raising
133
154
  ```
134
155
 
156
+ ### Library Defaults
157
+
158
+ Libraries own their defaults and register them with
159
+ `Legion::Settings.register_library(:my_library, defaults)`. Existing configuration
160
+ values take precedence. Registration is idempotent for the active loader; after
161
+ replacing the loader or calling `load(force: true)`, a library can register its
162
+ defaults again without losing configuration overrides.
163
+
164
+ Settings and overlays are local to the process. Registration does not synchronize
165
+ configuration across processes or provide durable shared state.
166
+
135
167
  ### Logging Defaults
136
168
 
137
169
  The `logging` key includes a `transport` sub-section that controls whether log events are forwarded over the message bus:
@@ -226,7 +226,7 @@ module Legion
226
226
  mark_dirty!
227
227
  end
228
228
 
229
- def load_file(file)
229
+ def load_file(file, strict: false, **)
230
230
  log.debug("Trying to load file #{file}")
231
231
  if File.file?(file) && File.readable?(file)
232
232
  begin
@@ -239,9 +239,11 @@ module Legion
239
239
  rescue Legion::JSON::ParseError => e
240
240
  log.error("config file must be valid json: #{file}")
241
241
  log.error(" parse error: #{e.message}")
242
+ raise if strict
242
243
  end
243
244
  else
244
245
  log.warn("Config file does not exist or is not readable file:#{file}")
246
+ raise Error, "Config file does not exist or is not readable: #{file}" if strict
245
247
  end
246
248
  end
247
249
 
@@ -337,10 +339,16 @@ module Legion
337
339
  end
338
340
  end
339
341
 
340
- def with_indifferent_access(hash)
341
- hash = indifferent_hash.merge(hash)
342
- hash.each do |key, value|
343
- hash[key] = with_indifferent_access(value) if value.is_a?(Hash)
342
+ def with_indifferent_access(value)
343
+ case value
344
+ when Hash
345
+ value.each_with_object(indifferent_hash) do |(key, child), result|
346
+ result[key] = with_indifferent_access(child)
347
+ end
348
+ when Array
349
+ value.map { |child| with_indifferent_access(child) }
350
+ else
351
+ value
344
352
  end
345
353
  end
346
354
 
@@ -1,81 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging'
4
+ require 'weakref'
4
5
 
5
6
  module Legion
6
7
  module Settings
7
8
  module Resolver
8
9
  extend Legion::Logging::Helper
9
10
 
10
- VAULT_PATTERN = %r{\Avault://(.+?)#(.+)\z}
11
+ # The delimiter follows a nonempty path/name; subsequent path characters cannot consume it.
12
+ VAULT_PATTERN = %r{\Avault://([^\n][^#\n]*)#([^\n]+)\z}
11
13
  ENV_PATTERN = %r{\Aenv://(.+)\z}
12
- LEASE_PATTERN = %r{\Alease://(.+?)#(.+)\z}
14
+ LEASE_PATTERN = %r{\Alease://([^\n][^#\n]*)#([^\n]+)\z}
13
15
  URI_PATTERN = %r{\A(?:vault|env|lease)://}
14
16
 
17
+ @source_mutex = Mutex.new
18
+ @source_states = {}
19
+
15
20
  module_function
16
21
 
17
- def resolve_secrets!(settings_hash)
22
+ def resolve_secrets!(settings_hash, owner: settings_hash, previous_owner: nil, strict: false, publish: nil)
18
23
  return settings_hash unless settings_hash.is_a?(Hash)
19
24
 
20
- @vault_available = vault_connected?
21
- @vault_cache = {}
22
-
23
- vault_count = count_vault_refs(settings_hash)
24
- log.warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
25
-
26
- lease_count = count_lease_refs(settings_hash)
27
- log.warn("LeaseManager not available — #{lease_count} lease:// reference(s) will not be resolved") if lease_count.positive? && !lease_manager_available?
25
+ state = resolution_state(owner: owner)
26
+ previous = previous_owner ? resolution_state(owner: previous_owner) : state
27
+ state[:mutex].synchronize do
28
+ context = { vault_available: vault_connected?, vault_cache: {}, sources: state[:sources], strict: strict }
29
+ prefetch_vault(value: settings_hash, context: context)
30
+ state[:sources].each_value { |record| prefetch_vault(value: record[:source], context: context) }
31
+ publication = lambda do |proposal|
32
+ publish&.call(proposal)
33
+ state[:sources] = proposal[:sources]
34
+ end
35
+ preparation = lambda do |lease_reader|
36
+ context[:lease_reader] = lease_reader
37
+ proposal = { paths: previous[:sources].filter_map { |path, record| path if record[:lease] },
38
+ assignments: [], references: [], sources: {}, resolved: 0, unresolved: 0 }
39
+ walk(settings_hash, path: [], context: context, proposal: proposal)
40
+ proposal[:paths].uniq!
41
+ proposal
42
+ end
28
43
 
29
- resolved = 0
30
- unresolved = 0
31
- walk(settings_hash, path: '') do |result|
32
- if result == :resolved
33
- resolved += 1
34
- elsif result == :unresolved
35
- unresolved += 1
44
+ needs_leases = lease_sources?(settings: settings_hash, sources: state[:sources], previous_sources: previous[:sources])
45
+ proposal = if needs_leases && lease_manager_available?
46
+ manager = Legion::Crypt::LeaseManager.instance
47
+ unless manager.respond_to?(:update_settings_refs)
48
+ raise 'Lease resolution requires legion-crypt >= 1.5.38 for atomic settings reference replacement'
49
+ end
50
+
51
+ manager.update_settings_refs(settings: settings_hash, publish: publication, &preparation)
52
+ else
53
+ apply_resolution(proposal: preparation.call(nil), publish: publication)
54
+ end
55
+ if proposal[:resolved].positive? || proposal[:unresolved].positive?
56
+ log.info("Settings resolver: #{proposal[:resolved]} resolved, #{proposal[:unresolved]} unresolved")
36
57
  end
37
58
  end
59
+ settings_hash
60
+ end
38
61
 
39
- log.info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
62
+ # Strong state values keep provenance alive across GC; weak owner references
63
+ # avoid retaining retired loaders and never alter Hash serialization or dup.
64
+ def resolution_state(owner:)
65
+ @source_mutex.synchronize do
66
+ @source_states.delete_if { |_id, state| !state[:owner].weakref_alive? }
67
+ # Identity keys must not retain owners; compare_by_identity would hold them strongly.
68
+ @source_states[owner.object_id] ||= { owner: WeakRef.new(owner), mutex: Mutex.new, sources: {} } # rubocop:disable Lint/HashCompareByIdentity
69
+ end
70
+ end
40
71
 
41
- settings_hash
72
+ def lease_sources?(settings:, sources:, previous_sources:)
73
+ count_lease_refs(settings).positive? ||
74
+ sources.any? { |_path, record| count_lease_refs(record[:source]).positive? } ||
75
+ previous_sources.any? { |_path, record| record[:lease] }
42
76
  end
43
77
 
44
- def resolve_value(value)
78
+ def resolve_value(value, context: nil)
45
79
  case value
46
80
  when String
47
81
  return value unless value.match?(URI_PATTERN)
48
82
 
49
- resolve_single(value)
83
+ resolve_single(value, context: context)
50
84
  when Array
51
85
  return value unless resolvable_chain?(value)
52
86
 
53
- resolve_chain(value)
87
+ resolve_chain(value, context: context)
54
88
  else
55
89
  value
56
90
  end
57
91
  end
58
92
 
59
- def resolve_single(str)
60
- if (m = str.match(VAULT_PATTERN))
61
- resolve_vault(m[1], m[2])
62
- elsif (m = str.match(LEASE_PATTERN))
63
- resolve_lease(m[1], m[2])
64
- elsif (m = str.match(ENV_PATTERN))
65
- ENV.fetch(m[1], nil)
93
+ def resolve_single(str, context: nil)
94
+ if (match = str.match(VAULT_PATTERN))
95
+ resolve_vault(match[1], match[2], context: context)
96
+ elsif (match = str.match(LEASE_PATTERN))
97
+ resolve_lease(match[1], match[2], context: context)
98
+ elsif (match = str.match(ENV_PATTERN))
99
+ ENV.fetch(match[1], nil)
66
100
  else
67
101
  str
68
102
  end
69
103
  end
70
104
 
71
- def resolve_chain(arr)
105
+ def resolve_chain(arr, context: nil)
106
+ context ||= { vault_available: vault_connected?, vault_cache: {} }
72
107
  arr.each do |entry|
73
108
  result = if entry.is_a?(String) && entry.match?(URI_PATTERN)
74
- resolve_single(entry)
109
+ resolve_single(entry, context: context)
75
110
  else
76
111
  entry
77
112
  end
78
- return result unless result.nil?
113
+ next if result.nil?
114
+
115
+ yield(entry) if block_given?
116
+ return result
79
117
  end
80
118
  nil
81
119
  end
@@ -95,98 +133,122 @@ module Legion
95
133
 
96
134
  def vault_connected?
97
135
  return false unless defined?(Legion::Crypt)
98
- return false unless defined?(Legion::Settings)
99
136
  return Legion::Crypt.vault_connected? if Legion::Crypt.respond_to?(:vault_connected?)
100
137
 
101
- Legion::Settings[:crypt][:vault][:connected] == true ||
138
+ Legion::Settings[:crypt]&.dig(:vault, :connected) == true ||
102
139
  (Legion::Crypt.respond_to?(:connected_clusters) && Legion::Crypt.connected_clusters.any?)
103
140
  rescue StandardError => e
104
141
  log.debug("Legion::Settings::Resolver#vault_connected? failed: #{e.message}")
105
142
  false
106
143
  end
107
144
 
108
- def walk(hash, path:, &)
109
- case hash
110
- when Hash
111
- hash.each do |key, value|
112
- current_path = path.empty? ? key.to_s : "#{path}.#{key}"
113
- walk_value(hash, key, value, current_path, &)
114
- end
115
- when Array
116
- hash.each_with_index do |value, index|
117
- current_path = "#{path}[#{index}]"
118
- walk_value(hash, index, value, current_path, &)
119
- end
145
+ def walk(container, path:, context:, proposal:)
146
+ keys = container.is_a?(Hash) ? container.each_key : container.each_index
147
+ keys.each do |key|
148
+ current_path = (path + [key]).freeze
149
+ next if prepare_value(container: container, key: key, path: current_path, context: context, proposal: proposal)
150
+
151
+ value = container[key]
152
+ walk(value, path: current_path, context: context, proposal: proposal) if value.is_a?(Hash) || value.is_a?(Array)
120
153
  end
121
154
  end
122
155
 
123
- def walk_value(container, key, value, current_path, &)
124
- case value
125
- when Hash
126
- walk(value, path: current_path, &)
127
- when String
128
- handle_string_value(container, key, value, current_path) { |status| yield(status) if block_given? }
129
- when Array
130
- handle_array_value(container, key, value, current_path) { |status| yield(status) if block_given? }
156
+ def source_declaration(value:, record:)
157
+ if declaration?(value: value)
158
+ return value.dup.freeze unless value.is_a?(Array)
159
+
160
+ value.map { |entry| entry.is_a?(String) ? entry.dup.freeze : entry }.freeze
161
+ elsif record && value == record[:value]
162
+ record[:source]
131
163
  end
132
164
  end
133
165
 
134
- def handle_string_value(container, key, value, current_path)
135
- return unless value.match?(URI_PATTERN)
166
+ def declaration?(value:)
167
+ (value.is_a?(String) && value.match?(URI_PATTERN)) ||
168
+ (value.is_a?(Array) && resolvable_chain?(value) && value.none? { |entry| entry.is_a?(Hash) || entry.is_a?(Array) })
169
+ end
136
170
 
137
- resolved = resolve_single(value)
138
- if resolved.nil?
139
- log.warn("Settings resolver: could not resolve #{current_path} (#{value})")
140
- yield(:unresolved) if block_given?
171
+ def prepare_value(container:, key:, path:, context:, proposal:)
172
+ record = context[:sources][path]
173
+ source = source_declaration(value: container[key], record: record)
174
+ return unless source
175
+
176
+ winner = source
177
+ value = if source.is_a?(Array)
178
+ resolve_chain(source, context: context) { |entry| winner = entry }
179
+ else
180
+ resolve_single(source, context: context)
181
+ end
182
+ if value.nil?
183
+ proposal[:unresolved] += 1
184
+ raise "Settings resolver: could not resolve #{path.inspect}" if context[:strict]
185
+
186
+ log.warn("Settings resolver: could not resolve #{path.inspect}")
187
+ return source unless record && container[key] == record[:value]
141
188
  else
142
- container[key] = resolved
143
- register_lease_ref(value, current_path) if value.match?(LEASE_PATTERN)
144
- yield(:resolved) if block_given?
189
+ lease = winner.is_a?(String) && winner.match(LEASE_PATTERN)
190
+ record = { source: source, value: value, lease: lease && { name: lease[1], key: lease[2] } }
191
+ proposal[:assignments] << { container: container, key: key, value: value }
192
+ proposal[:resolved] += 1
193
+ end
194
+ proposal[:sources][path] = record
195
+ if record[:lease]
196
+ proposal[:paths] << path
197
+ proposal[:references] << record[:lease].merge(path: path, binding: record)
145
198
  end
199
+ source
146
200
  end
147
201
 
148
- def handle_array_value(container, key, value, current_path, &)
149
- if resolvable_chain?(value) && value.all? { |entry| !entry.is_a?(Hash) && !entry.is_a?(Array) }
150
- resolved = resolve_chain(value)
151
- if resolved.nil?
152
- log.warn("Settings resolver: fallback chain exhausted for #{current_path}")
153
- yield(:unresolved) if block_given?
154
- else
155
- container[key] = resolved
156
- register_lease_refs_from_chain(value, current_path)
157
- yield(:resolved) if block_given?
158
- end
159
- else
160
- walk(value, path: current_path, &)
202
+ def apply_resolution(proposal:, publish:)
203
+ snapshots = proposal[:assignments].map do |assignment|
204
+ container = assignment[:container]
205
+ raise FrozenError, 'Settings resolver: destination is frozen' if container.frozen?
206
+
207
+ [container, assignment[:key], container[assignment[:key]]]
161
208
  end
209
+ begin
210
+ proposal[:assignments].each { |assignment| assignment[:container][assignment[:key]] = assignment[:value] }
211
+ publish.call(proposal)
212
+ rescue StandardError
213
+ snapshots.reverse_each { |container, key, value| container[key] = value }
214
+ raise
215
+ end
216
+ proposal
162
217
  end
163
218
 
164
- def resolve_vault(path, key)
165
- log.debug("resolve_vault: path=#{path}, key=#{key}, vault_available=#{@vault_available}")
166
- return nil unless @vault_available
167
-
168
- @vault_cache[path] ||= begin
169
- log.debug("resolve_vault: calling Legion::Crypt.read(#{path.inspect})")
170
- result = Legion::Crypt.read(path)
171
- log.debug("resolve_vault: read returned #{result.nil? ? 'nil' : "keys=#{result.keys.inspect}"}")
172
- result
173
- rescue StandardError => e
174
- log.warn("Settings resolver: vault read failed for #{path}: #{e.class}=#{e.message}")
175
- nil
219
+ def prefetch_vault(value:, context:)
220
+ case value
221
+ when String
222
+ match = value.match(VAULT_PATTERN)
223
+ resolve_vault(match[1], match[2], context: context) if match
224
+ when Array
225
+ value.each { |entry| prefetch_vault(value: entry, context: context) }
226
+ when Hash
227
+ value.each_value { |entry| prefetch_vault(value: entry, context: context) }
176
228
  end
229
+ end
177
230
 
178
- data = @vault_cache[path]
179
- unless data.is_a?(Hash)
180
- log.debug("resolve_vault: data at #{path} is #{data.class}, returning nil")
181
- return nil
231
+ def resolve_vault(path, key, context: nil)
232
+ context ||= { vault_available: vault_connected?, vault_cache: {} }
233
+ return nil unless context[:vault_available]
234
+
235
+ cache = context[:vault_cache]
236
+ unless cache.key?(path)
237
+ cache[path] = begin
238
+ Legion::Crypt.read(path)
239
+ rescue StandardError => e
240
+ log.warn("Settings resolver: vault read failed for #{path}: #{e.class}=#{e.message}")
241
+ nil
242
+ end
182
243
  end
244
+ data = cache[path]
245
+ return nil unless data.is_a?(Hash)
183
246
 
184
- value = data[key.to_sym] || data[key.to_s]
185
- log.debug("resolve_vault: #{path}##{key} = #{value.nil? ? 'nil' : '<present>'}")
186
- value
247
+ data.key?(key.to_sym) ? data[key.to_sym] : data[key.to_s]
187
248
  end
188
249
 
189
- def resolve_lease(name, key)
250
+ def resolve_lease(name, key, context: nil)
251
+ return context[:lease_reader].call(name: name, key: key) if context && context[:lease_reader]
190
252
  return nil unless lease_manager_available?
191
253
 
192
254
  Legion::Crypt::LeaseManager.instance.fetch(name, key)
@@ -197,36 +259,10 @@ module Legion
197
259
 
198
260
  def lease_manager_available?
199
261
  defined?(Legion::Crypt::LeaseManager)
200
- rescue StandardError => e
201
- log.debug("Legion::Settings::Resolver#lease_manager_available? failed: #{e.message}")
202
- false
203
262
  end
204
263
 
205
264
  def resolvable_chain?(arr)
206
- arr.any? { |v| v.is_a?(String) && v.match?(URI_PATTERN) }
207
- end
208
-
209
- def register_lease_ref(value, path_string)
210
- return unless lease_manager_available?
211
-
212
- m = value.match(LEASE_PATTERN)
213
- return unless m
214
-
215
- path_parts = path_string.split('.').map(&:to_sym)
216
- Legion::Crypt::LeaseManager.instance.register_ref(m[1], m[2], path_parts)
217
- rescue StandardError => e
218
- log.debug("Legion::Settings::Resolver#register_lease_ref failed for #{path_string}: #{e.message}")
219
- nil
220
- end
221
-
222
- def register_lease_refs_from_chain(arr, path_string)
223
- return unless lease_manager_available?
224
-
225
- arr.each do |entry|
226
- next unless entry.is_a?(String)
227
-
228
- register_lease_ref(entry, path_string) if entry.match?(LEASE_PATTERN)
229
- end
265
+ arr.any? { |value| value.is_a?(String) && value.match?(URI_PATTERN) }
230
266
  end
231
267
 
232
268
  def count_lease_refs(hash)
@@ -242,6 +278,9 @@ module Legion
242
278
  raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
243
279
  raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
244
280
  end
281
+
282
+ private_class_method :resolution_state, :lease_sources?, :walk, :source_declaration, :declaration?,
283
+ :prepare_value, :apply_resolution, :prefetch_vault
245
284
  end
246
285
  end
247
286
  end
@@ -181,8 +181,8 @@ module Legion
181
181
  def find_similar(key, candidates)
182
182
  key_str = key.to_s
183
183
  candidates.map(&:to_s).select { |c| levenshtein(key_str, c) <= 2 }
184
- .min_by { |c| levenshtein(key_str, c) }
185
- &.to_sym
184
+ .min_by { |c| levenshtein(key_str, c) }
185
+ &.to_sym
186
186
  end
187
187
 
188
188
  def levenshtein(str_a, str_b)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.4.0'
5
+ VERSION = '1.4.4'
6
6
  end
7
7
  end
@@ -115,10 +115,12 @@ module Legion
115
115
  # Legion::Settings.register_library(:transport, Legion::Transport::Settings.default)
116
116
  def register_library(key, defaults)
117
117
  sym = key.to_sym
118
- return if @registered_libraries&.include?(sym)
118
+ ensure_loader
119
+ registered = @registered_libraries&.include?(sym)
120
+ return if registered && @loader.merged_modules.key?(sym)
119
121
 
120
122
  merge_settings(sym, defaults)
121
- (@registered_libraries ||= []) << sym
123
+ (@registered_libraries ||= []) << sym unless registered
122
124
  end
123
125
 
124
126
  def define_schema(key, overrides)
@@ -190,10 +192,13 @@ module Legion
190
192
  end
191
193
 
192
194
  def resolve_secrets!
193
- ensure_loader
194
- require 'legion/settings/resolver'
195
- Resolver.resolve_secrets!(@loader.to_hash)
196
- logger.debug('Secret resolution complete')
195
+ @reload_mutex ||= Mutex.new
196
+ @reload_mutex.synchronize do
197
+ ensure_loader
198
+ require 'legion/settings/resolver'
199
+ Resolver.resolve_secrets!(@loader.to_hash, owner: @loader)
200
+ logger.debug('Secret resolution complete')
201
+ end
197
202
  end
198
203
 
199
204
  def schema
@@ -216,17 +221,16 @@ module Legion
216
221
  # ------------------------------------------------------------------
217
222
  def reload!
218
223
  @reload_mutex ||= Mutex.new
219
- @reload_mutex.synchronize do
224
+ changes = @reload_mutex.synchronize do
220
225
  return {} unless @loader
221
226
 
222
- old_hash = @loader.to_hash.dup
223
227
  files = @loader.loaded_files.dup
224
228
 
225
229
  # Re-create loader and replay the same files
226
230
  new_loader = Legion::Settings::Loader.new
227
231
  new_loader.load_env
228
232
  new_loader.load_dns_bootstrap
229
- files.each { |f| new_loader.load_file(f) if File.exist?(f) }
233
+ files.each { |file| new_loader.load_file(file, strict: true) if File.exist?(file) }
230
234
 
231
235
  # Replay module merges so extension defaults are preserved
232
236
  if @loader.respond_to?(:merged_modules)
@@ -238,27 +242,28 @@ module Legion
238
242
  # Replay project env overrides (.legionio.env)
239
243
  load_project_env(loader: new_loader)
240
244
 
241
- # Re-resolve secrets (vault://, env://, lease://)
242
- begin
243
- require 'legion/settings/resolver'
244
- Resolver.resolve_secrets!(new_loader.to_hash)
245
- rescue StandardError => e
246
- logger.warn("Settings reload: secret resolution failed: #{e.message}")
245
+ require 'legion/settings/resolver'
246
+ changes = nil
247
+ publication = lambda do |_proposal|
248
+ changes = diff_settings(@loader.to_hash, new_loader.to_hash)
249
+ @loader = new_loader
247
250
  end
248
-
249
- new_hash = new_loader.to_hash
250
- changes = diff_settings(old_hash, new_hash)
251
+ Resolver.resolve_secrets!(new_loader.to_hash, owner: new_loader, previous_owner: @loader,
252
+ strict: true, publish: publication)
251
253
 
252
254
  if changes.empty?
253
255
  logger.info('Settings reload: no changes detected')
254
256
  else
255
- @loader = new_loader
256
257
  logger.info("Settings reload: #{changes.size} key(s) changed — #{changes.keys.join(', ')}")
257
- fire_reload_callbacks(changes)
258
258
  end
259
259
 
260
260
  changes
261
261
  end
262
+ fire_reload_callbacks(changes) unless changes.empty?
263
+ changes
264
+ rescue StandardError => e
265
+ logger.warn("Settings reload failed: #{e.class}: #{e.message}")
266
+ raise
262
267
  end
263
268
 
264
269
  # Register a SIGHUP handler that triggers reload!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity