legion-settings 1.2.2 → 1.3.1
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 +15 -0
- data/CLAUDE.md +40 -0
- data/README.md +31 -2
- data/lib/legion/settings/resolver.rb +235 -0
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +6 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb0e2c44578e9e5b6cbb495c5160e1a37c067ea5692979e50e89494d33da9888
|
|
4
|
+
data.tar.gz: ebca4fdb1fb58e2fa315620209ccbe5f5302057a7dc0d4a256f9689ea8d5d8b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 738f2ced6bf58fcb2d9ad4fca307d63abea4241bd6c5e788ff3ee47346f8f1054fd1cceb94d86e2f393f7985a0a835076513b94de730a89e9e233d923d5c1775
|
|
7
|
+
data.tar.gz: ac60c369c84bd0dcb53b84f5fcdf9440fcd25ea00d15bb32386b67a4b999ceead7dde040fa66de53c9e6b06a5e99628b674923d0503dcbbe945a5b30a7d88bb6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Legion::Settings Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.1] - 2026-03-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `lease://name#key` URI scheme in secret resolver for dynamic Vault leases
|
|
7
|
+
- Delegates to `Legion::Crypt::LeaseManager` for lease data lookup
|
|
8
|
+
- Registers reverse references for push-back on credential rotation
|
|
9
|
+
|
|
10
|
+
## [1.3.0] - 2026-03-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Universal secret resolver: `vault://` and `env://` URI references in any settings value
|
|
14
|
+
- Fallback chain support via arrays (first non-nil wins)
|
|
15
|
+
- `Legion::Settings.resolve_secrets!` method for explicit resolution phase
|
|
16
|
+
- Vault read caching within a single resolution pass
|
|
17
|
+
|
|
3
18
|
## [1.2.2] - 2026-03-16
|
|
4
19
|
|
|
5
20
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -68,6 +68,7 @@ Legion::Settings (singleton module)
|
|
|
68
68
|
| `lib/legion/settings/schema.rb` | Type inference, validation logic, unknown key detection (Levenshtein) |
|
|
69
69
|
| `lib/legion/settings/validation_error.rb` | Error collection and formatted reporting |
|
|
70
70
|
| `lib/legion/settings/os.rb` | OS detection helpers |
|
|
71
|
+
| `lib/legion/settings/resolver.rb` | Secret resolution: `vault://` and `env://` URI references, fallback chains |
|
|
71
72
|
| `lib/legion/settings/version.rb` | VERSION constant |
|
|
72
73
|
| `spec/legion/settings_spec.rb` | Core settings module tests |
|
|
73
74
|
| `spec/legion/settings_module_spec.rb` | Module-level accessor and merge tests |
|
|
@@ -76,6 +77,45 @@ Legion::Settings (singleton module)
|
|
|
76
77
|
| `spec/legion/settings/validation_error_spec.rb` | Error formatting tests |
|
|
77
78
|
| `spec/legion/settings/integration_spec.rb` | End-to-end validation tests |
|
|
78
79
|
| `spec/legion/settings/role_defaults_spec.rb` | Role profile default settings tests |
|
|
80
|
+
| `spec/legion/settings/resolver_spec.rb` | Secret resolver tests (env://, vault://, lease://, fallback chains) |
|
|
81
|
+
|
|
82
|
+
## Secret Resolution
|
|
83
|
+
|
|
84
|
+
Settings values can reference external secret sources using URI syntax. Resolved in-place via `Legion::Settings.resolve_secrets!` (called automatically after `Legion::Crypt.start` in the boot sequence).
|
|
85
|
+
|
|
86
|
+
### URI Schemes
|
|
87
|
+
|
|
88
|
+
| Scheme | Format | Resolution |
|
|
89
|
+
|--------|--------|------------|
|
|
90
|
+
| `vault://` | `vault://path/to/secret#key` | `Legion::Crypt.read(path)[key]` (static KV secrets) |
|
|
91
|
+
| `env://` | `env://ENV_VAR_NAME` | `ENV['ENV_VAR_NAME']` |
|
|
92
|
+
| `lease://` | `lease://name#key` | `Legion::Crypt::LeaseManager.instance.fetch(name, key)` (dynamic Vault leases) |
|
|
93
|
+
| *(plain string)* | `"guest"` | Returned as-is |
|
|
94
|
+
|
|
95
|
+
### Fallback Chains
|
|
96
|
+
|
|
97
|
+
Array values are tried in order — first non-nil wins:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"transport": {
|
|
102
|
+
"connection": {
|
|
103
|
+
"password": ["vault://secret/data/rabbitmq#password", "env://RABBITMQ_PASSWORD", "guest"]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Logging Strategy
|
|
110
|
+
|
|
111
|
+
- Vault not connected + vault refs exist: one summary warning with count
|
|
112
|
+
- Individual vault path failures: debug level
|
|
113
|
+
- Entire chain resolves to nil: one warning per key path
|
|
114
|
+
- Success: info summary with resolved counts
|
|
115
|
+
|
|
116
|
+
### Implementation
|
|
117
|
+
|
|
118
|
+
`Legion::Settings::Resolver` module with `module_function`. Called via `Legion::Settings.resolve_secrets!` which delegates to `Resolver.resolve_secrets!(@loader.to_hash)`. Vault reads are cached by path within a single resolution pass.
|
|
79
119
|
|
|
80
120
|
## Role in LegionIO
|
|
81
121
|
|
data/README.md
CHANGED
|
@@ -28,11 +28,40 @@ Legion::Settings[:transport][:connection][:host]
|
|
|
28
28
|
### Config Paths (checked in order)
|
|
29
29
|
|
|
30
30
|
1. `/etc/legionio/`
|
|
31
|
-
2.
|
|
32
|
-
3.
|
|
31
|
+
2. `~/.legionio/settings/`
|
|
32
|
+
3. `~/legionio/`
|
|
33
|
+
4. `./settings/`
|
|
33
34
|
|
|
34
35
|
Each Legion module registers its own defaults via `merge_settings` during startup.
|
|
35
36
|
|
|
37
|
+
### Secret Resolution
|
|
38
|
+
|
|
39
|
+
Settings values can reference external secret sources using URI syntax. Two schemes are supported:
|
|
40
|
+
|
|
41
|
+
| Scheme | Format | Resolution |
|
|
42
|
+
|--------|--------|------------|
|
|
43
|
+
| `vault://` | `vault://path/to/secret#key` | Reads from HashiCorp Vault via `Legion::Crypt` |
|
|
44
|
+
| `env://` | `env://ENV_VAR_NAME` | Reads from environment variable |
|
|
45
|
+
|
|
46
|
+
Array values act as fallback chains — the first non-nil result wins:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"transport": {
|
|
51
|
+
"connection": {
|
|
52
|
+
"password": ["vault://secret/data/rabbitmq#password", "env://RABBITMQ_PASSWORD", "guest"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Legion::Settings.resolve_secrets!
|
|
62
|
+
# All vault:// and env:// references are now replaced with their resolved values
|
|
63
|
+
```
|
|
64
|
+
|
|
36
65
|
### Schema Validation
|
|
37
66
|
|
|
38
67
|
Types are inferred automatically from default values. Optional constraints can be added:
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Settings
|
|
5
|
+
module Resolver
|
|
6
|
+
VAULT_PATTERN = %r{\Avault://(.+?)#(.+)\z}
|
|
7
|
+
ENV_PATTERN = %r{\Aenv://(.+)\z}
|
|
8
|
+
LEASE_PATTERN = %r{\Alease://(.+?)#(.+)\z}
|
|
9
|
+
URI_PATTERN = %r{\A(?:vault|env|lease)://}
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve_secrets!(settings_hash)
|
|
14
|
+
return settings_hash unless settings_hash.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
@vault_available = vault_connected?
|
|
17
|
+
@vault_cache = {}
|
|
18
|
+
|
|
19
|
+
vault_count = count_vault_refs(settings_hash)
|
|
20
|
+
log_warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
|
|
21
|
+
|
|
22
|
+
lease_count = count_lease_refs(settings_hash)
|
|
23
|
+
log_warn("LeaseManager not available — #{lease_count} lease:// reference(s) will not be resolved") if lease_count.positive? && !lease_manager_available?
|
|
24
|
+
|
|
25
|
+
resolved = 0
|
|
26
|
+
unresolved = 0
|
|
27
|
+
walk(settings_hash, path: '') do |result|
|
|
28
|
+
if result == :resolved
|
|
29
|
+
resolved += 1
|
|
30
|
+
elsif result == :unresolved
|
|
31
|
+
unresolved += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
log_info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
|
|
36
|
+
|
|
37
|
+
settings_hash
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_value(value)
|
|
41
|
+
case value
|
|
42
|
+
when String
|
|
43
|
+
return value unless value.match?(URI_PATTERN)
|
|
44
|
+
|
|
45
|
+
resolve_single(value)
|
|
46
|
+
when Array
|
|
47
|
+
return value unless resolvable_chain?(value)
|
|
48
|
+
|
|
49
|
+
resolve_chain(value)
|
|
50
|
+
else
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolve_single(str)
|
|
56
|
+
if (m = str.match(VAULT_PATTERN))
|
|
57
|
+
resolve_vault(m[1], m[2])
|
|
58
|
+
elsif (m = str.match(LEASE_PATTERN))
|
|
59
|
+
resolve_lease(m[1], m[2])
|
|
60
|
+
elsif (m = str.match(ENV_PATTERN))
|
|
61
|
+
ENV.fetch(m[1], nil)
|
|
62
|
+
else
|
|
63
|
+
str
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_chain(arr)
|
|
68
|
+
arr.each do |entry|
|
|
69
|
+
result = if entry.is_a?(String) && entry.match?(URI_PATTERN)
|
|
70
|
+
resolve_single(entry)
|
|
71
|
+
else
|
|
72
|
+
entry
|
|
73
|
+
end
|
|
74
|
+
return result unless result.nil?
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def has_vault_refs?(hash) # rubocop:disable Naming/PredicatePrefix
|
|
80
|
+
count_vault_refs(hash).positive?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def count_vault_refs(hash)
|
|
84
|
+
return 0 unless hash.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
hash.sum do |_key, value|
|
|
87
|
+
case value
|
|
88
|
+
when String then value.match?(VAULT_PATTERN) ? 1 : 0
|
|
89
|
+
when Array then value.count { |v| v.is_a?(String) && v.match?(VAULT_PATTERN) }
|
|
90
|
+
when Hash then count_vault_refs(value)
|
|
91
|
+
else 0
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def vault_connected?
|
|
97
|
+
return false unless defined?(Legion::Crypt)
|
|
98
|
+
return false unless defined?(Legion::Settings)
|
|
99
|
+
|
|
100
|
+
Legion::Settings[:crypt][:vault][:connected] == true
|
|
101
|
+
rescue StandardError
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def walk(hash, path:, &block)
|
|
106
|
+
hash.each do |key, value|
|
|
107
|
+
current_path = path.empty? ? key.to_s : "#{path}.#{key}"
|
|
108
|
+
|
|
109
|
+
case value
|
|
110
|
+
when Hash
|
|
111
|
+
walk(value, path: current_path, &block)
|
|
112
|
+
when String
|
|
113
|
+
next unless value.match?(URI_PATTERN)
|
|
114
|
+
|
|
115
|
+
resolved = resolve_single(value)
|
|
116
|
+
if resolved.nil?
|
|
117
|
+
log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
|
|
118
|
+
block&.call(:unresolved)
|
|
119
|
+
else
|
|
120
|
+
hash[key] = resolved
|
|
121
|
+
register_lease_ref(value, current_path) if value.match?(LEASE_PATTERN)
|
|
122
|
+
block&.call(:resolved)
|
|
123
|
+
end
|
|
124
|
+
when Array
|
|
125
|
+
next unless resolvable_chain?(value)
|
|
126
|
+
|
|
127
|
+
resolved = resolve_chain(value)
|
|
128
|
+
if resolved.nil?
|
|
129
|
+
log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
|
|
130
|
+
block&.call(:unresolved)
|
|
131
|
+
else
|
|
132
|
+
hash[key] = resolved
|
|
133
|
+
register_lease_refs_from_chain(value, current_path)
|
|
134
|
+
block&.call(:resolved)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resolve_vault(path, key)
|
|
141
|
+
return nil unless @vault_available
|
|
142
|
+
|
|
143
|
+
@vault_cache[path] ||= begin
|
|
144
|
+
Legion::Crypt.read(path)
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
log_debug("Settings resolver: vault read failed for #{path}: #{e.message}")
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
data = @vault_cache[path]
|
|
151
|
+
return nil unless data.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
data[key.to_sym] || data[key.to_s]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def resolve_lease(name, key)
|
|
157
|
+
return nil unless lease_manager_available?
|
|
158
|
+
|
|
159
|
+
Legion::Crypt::LeaseManager.instance.fetch(name, key)
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
log_debug("Settings resolver: lease fetch failed for #{name}##{key}: #{e.message}")
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def lease_manager_available?
|
|
166
|
+
defined?(Legion::Crypt::LeaseManager)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolvable_chain?(arr)
|
|
172
|
+
arr.any? { |v| v.is_a?(String) && v.match?(URI_PATTERN) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def register_lease_ref(value, path_string)
|
|
176
|
+
return unless lease_manager_available?
|
|
177
|
+
|
|
178
|
+
m = value.match(LEASE_PATTERN)
|
|
179
|
+
return unless m
|
|
180
|
+
|
|
181
|
+
path_parts = path_string.split('.').map(&:to_sym)
|
|
182
|
+
Legion::Crypt::LeaseManager.instance.register_ref(m[1], m[2], path_parts)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def register_lease_refs_from_chain(arr, path_string)
|
|
188
|
+
return unless lease_manager_available?
|
|
189
|
+
|
|
190
|
+
arr.each do |entry|
|
|
191
|
+
next unless entry.is_a?(String)
|
|
192
|
+
|
|
193
|
+
register_lease_ref(entry, path_string) if entry.match?(LEASE_PATTERN)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def count_lease_refs(hash)
|
|
198
|
+
return 0 unless hash.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
hash.sum do |_key, value|
|
|
201
|
+
case value
|
|
202
|
+
when String then value.match?(LEASE_PATTERN) ? 1 : 0
|
|
203
|
+
when Array then value.count { |v| v.is_a?(String) && v.match?(LEASE_PATTERN) }
|
|
204
|
+
when Hash then count_lease_refs(value)
|
|
205
|
+
else 0
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def log_info(message)
|
|
211
|
+
if defined?(Legion::Logging)
|
|
212
|
+
Legion::Logging.info(message)
|
|
213
|
+
else
|
|
214
|
+
$stdout.puts(message)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def log_warn(message)
|
|
219
|
+
if defined?(Legion::Logging)
|
|
220
|
+
Legion::Logging.warn(message)
|
|
221
|
+
else
|
|
222
|
+
warn(message)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def log_debug(message)
|
|
227
|
+
if defined?(Legion::Logging)
|
|
228
|
+
Legion::Logging.debug(message)
|
|
229
|
+
else
|
|
230
|
+
$stdout.puts(message)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
data/lib/legion/settings.rb
CHANGED
|
@@ -79,6 +79,12 @@ module Legion
|
|
|
79
79
|
warn_validation_errors(errors)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def resolve_secrets!
|
|
83
|
+
@loader = load if @loader.nil?
|
|
84
|
+
require 'legion/settings/resolver'
|
|
85
|
+
Resolver.resolve_secrets!(@loader.to_hash)
|
|
86
|
+
end
|
|
87
|
+
|
|
82
88
|
def schema
|
|
83
89
|
@schema ||= Schema.new
|
|
84
90
|
end
|
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
|
+
version: 1.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- lib/legion/settings.rb
|
|
47
47
|
- lib/legion/settings/loader.rb
|
|
48
48
|
- lib/legion/settings/os.rb
|
|
49
|
+
- lib/legion/settings/resolver.rb
|
|
49
50
|
- lib/legion/settings/schema.rb
|
|
50
51
|
- lib/legion/settings/validation_error.rb
|
|
51
52
|
- lib/legion/settings/version.rb
|