legion-settings 1.4.2 → 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 +4 -4
- data/.github/workflows/ci.yml +4 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +3 -0
- data/README.md +36 -4
- data/lib/legion/settings/loader.rb +13 -5
- data/lib/legion/settings/resolver.rb +163 -124
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +25 -20
- 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: 5b4ca4881211bedb39799998f075d8539d5ebfdf79178f2d6c61e9ee1ebd28db
|
|
4
|
+
data.tar.gz: fcf3c214190e2263cb67e4d17c5f8ddaceb478e5fed16a9a8021ed2722e4d160
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3f2a51d7a7633bf404b5f965c374357abc4a70d5c9e8d69ebd1bee22dfd4b3c878a4ee3af9870d1a4d42fe471a973db3b9c765dca9fc835f105b8c6ecd8ce42
|
|
7
|
+
data.tar.gz: 34a30b2ec19e8c310c89a5c59b1d2b87ad01fddd09d5948738c3092cd6c5d77d86384a25aa1884bbe44fc27db288e929c1db863d2ef46b126b7b4d045e5c124b
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
3
25
|
## [1.4.2] - 2026-08-14
|
|
4
26
|
|
|
5
27
|
### Fixed
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
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(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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://(
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
61
|
-
resolve_vault(
|
|
62
|
-
elsif (
|
|
63
|
-
resolve_lease(
|
|
64
|
-
elsif (
|
|
65
|
-
ENV.fetch(
|
|
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
|
-
|
|
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]
|
|
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(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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? { |
|
|
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
|
data/lib/legion/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 { |
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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!
|