legion-settings 1.2.2 → 1.3.0
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 +8 -0
- data/CLAUDE.md +39 -0
- data/lib/legion/settings/resolver.rb +177 -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: b2fb957d328761484c232467b3f37dfea86359a31726a6f3910e2e02838e3977
|
|
4
|
+
data.tar.gz: f10dcf26575430c481b466ebf490e3331d2d1c8ba841b438ca15560c8554c965
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b69cabe6c5265040c40791eb9b0c9c284c42438b11b9c146113b57fa9dc6c87d4c28072a3ce569cf9a5f34d3151bac4adaf8ed0a923fd8cecbcf40b8aae6103c
|
|
7
|
+
data.tar.gz: cbeaf56feeaca1cb4a02fdf76cf501e82d5619d489228570ec0906033e437249e3d85e98db652072c73b145bb80162f66f206dc6e1b0c79266606b7e647bf922
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Legion::Settings Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-03-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Universal secret resolver: `vault://` and `env://` URI references in any settings value
|
|
7
|
+
- Fallback chain support via arrays (first non-nil wins)
|
|
8
|
+
- `Legion::Settings.resolve_secrets!` method for explicit resolution phase
|
|
9
|
+
- Vault read caching within a single resolution pass
|
|
10
|
+
|
|
3
11
|
## [1.2.2] - 2026-03-16
|
|
4
12
|
|
|
5
13
|
### 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,44 @@ 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://, 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]` |
|
|
91
|
+
| `env://` | `env://ENV_VAR_NAME` | `ENV['ENV_VAR_NAME']` |
|
|
92
|
+
| *(plain string)* | `"guest"` | Returned as-is |
|
|
93
|
+
|
|
94
|
+
### Fallback Chains
|
|
95
|
+
|
|
96
|
+
Array values are tried in order — first non-nil wins:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"transport": {
|
|
101
|
+
"connection": {
|
|
102
|
+
"password": ["vault://secret/data/rabbitmq#password", "env://RABBITMQ_PASSWORD", "guest"]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Logging Strategy
|
|
109
|
+
|
|
110
|
+
- Vault not connected + vault refs exist: one summary warning with count
|
|
111
|
+
- Individual vault path failures: debug level
|
|
112
|
+
- Entire chain resolves to nil: one warning per key path
|
|
113
|
+
- Success: info summary with resolved counts
|
|
114
|
+
|
|
115
|
+
### Implementation
|
|
116
|
+
|
|
117
|
+
`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
118
|
|
|
80
119
|
## Role in LegionIO
|
|
81
120
|
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
URI_PATTERN = %r{\A(?:vault|env)://}
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def resolve_secrets!(settings_hash)
|
|
13
|
+
return settings_hash unless settings_hash.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
@vault_available = vault_connected?
|
|
16
|
+
@vault_cache = {}
|
|
17
|
+
|
|
18
|
+
vault_count = count_vault_refs(settings_hash)
|
|
19
|
+
log_warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
|
|
20
|
+
|
|
21
|
+
resolved = 0
|
|
22
|
+
unresolved = 0
|
|
23
|
+
walk(settings_hash, path: '') do |result|
|
|
24
|
+
if result == :resolved
|
|
25
|
+
resolved += 1
|
|
26
|
+
elsif result == :unresolved
|
|
27
|
+
unresolved += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
log_info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
|
|
32
|
+
|
|
33
|
+
settings_hash
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_value(value)
|
|
37
|
+
case value
|
|
38
|
+
when String
|
|
39
|
+
return value unless value.match?(URI_PATTERN)
|
|
40
|
+
|
|
41
|
+
resolve_single(value)
|
|
42
|
+
when Array
|
|
43
|
+
return value unless resolvable_chain?(value)
|
|
44
|
+
|
|
45
|
+
resolve_chain(value)
|
|
46
|
+
else
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve_single(str)
|
|
52
|
+
if (m = str.match(VAULT_PATTERN))
|
|
53
|
+
resolve_vault(m[1], m[2])
|
|
54
|
+
elsif (m = str.match(ENV_PATTERN))
|
|
55
|
+
ENV.fetch(m[1], nil)
|
|
56
|
+
else
|
|
57
|
+
str
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_chain(arr)
|
|
62
|
+
arr.each do |entry|
|
|
63
|
+
result = if entry.is_a?(String) && entry.match?(URI_PATTERN)
|
|
64
|
+
resolve_single(entry)
|
|
65
|
+
else
|
|
66
|
+
entry
|
|
67
|
+
end
|
|
68
|
+
return result unless result.nil?
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def has_vault_refs?(hash) # rubocop:disable Naming/PredicatePrefix
|
|
74
|
+
count_vault_refs(hash).positive?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def count_vault_refs(hash)
|
|
78
|
+
return 0 unless hash.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
hash.sum do |_key, value|
|
|
81
|
+
case value
|
|
82
|
+
when String then value.match?(VAULT_PATTERN) ? 1 : 0
|
|
83
|
+
when Array then value.count { |v| v.is_a?(String) && v.match?(VAULT_PATTERN) }
|
|
84
|
+
when Hash then count_vault_refs(value)
|
|
85
|
+
else 0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def vault_connected?
|
|
91
|
+
return false unless defined?(Legion::Crypt)
|
|
92
|
+
return false unless defined?(Legion::Settings)
|
|
93
|
+
|
|
94
|
+
Legion::Settings[:crypt][:vault][:connected] == true
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def walk(hash, path:, &block)
|
|
100
|
+
hash.each do |key, value|
|
|
101
|
+
current_path = path.empty? ? key.to_s : "#{path}.#{key}"
|
|
102
|
+
|
|
103
|
+
case value
|
|
104
|
+
when Hash
|
|
105
|
+
walk(value, path: current_path, &block)
|
|
106
|
+
when String
|
|
107
|
+
next unless value.match?(URI_PATTERN)
|
|
108
|
+
|
|
109
|
+
resolved = resolve_single(value)
|
|
110
|
+
if resolved.nil?
|
|
111
|
+
log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
|
|
112
|
+
block&.call(:unresolved)
|
|
113
|
+
else
|
|
114
|
+
hash[key] = resolved
|
|
115
|
+
block&.call(:resolved)
|
|
116
|
+
end
|
|
117
|
+
when Array
|
|
118
|
+
next unless resolvable_chain?(value)
|
|
119
|
+
|
|
120
|
+
resolved = resolve_chain(value)
|
|
121
|
+
if resolved.nil?
|
|
122
|
+
log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
|
|
123
|
+
block&.call(:unresolved)
|
|
124
|
+
else
|
|
125
|
+
hash[key] = resolved
|
|
126
|
+
block&.call(:resolved)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_vault(path, key)
|
|
133
|
+
return nil unless @vault_available
|
|
134
|
+
|
|
135
|
+
@vault_cache[path] ||= begin
|
|
136
|
+
Legion::Crypt.read(path)
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
log_debug("Settings resolver: vault read failed for #{path}: #{e.message}")
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
data = @vault_cache[path]
|
|
143
|
+
return nil unless data.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
data[key.to_sym] || data[key.to_s]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolvable_chain?(arr)
|
|
149
|
+
arr.any? { |v| v.is_a?(String) && v.match?(URI_PATTERN) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def log_info(message)
|
|
153
|
+
if defined?(Legion::Logging)
|
|
154
|
+
Legion::Logging.info(message)
|
|
155
|
+
else
|
|
156
|
+
$stdout.puts(message)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def log_warn(message)
|
|
161
|
+
if defined?(Legion::Logging)
|
|
162
|
+
Legion::Logging.warn(message)
|
|
163
|
+
else
|
|
164
|
+
warn(message)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def log_debug(message)
|
|
169
|
+
if defined?(Legion::Logging)
|
|
170
|
+
Legion::Logging.debug(message)
|
|
171
|
+
else
|
|
172
|
+
$stdout.puts(message)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
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.0
|
|
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
|