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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e6bfc5465268327a07174136c9b4cc7eb8793345bc43b61bbefbf99fdf2406a
4
- data.tar.gz: 9e6b1384a02334a6aecbe50474f184bfb7996d16efe36d9b80744a4feb6171bc
3
+ metadata.gz: b2fb957d328761484c232467b3f37dfea86359a31726a6f3910e2e02838e3977
4
+ data.tar.gz: f10dcf26575430c481b466ebf490e3331d2d1c8ba841b438ca15560c8554c965
5
5
  SHA512:
6
- metadata.gz: c8d084a1a0b75578ada3bb4f765bf7948ed9f9969e03717d10bf357d82895d1717b7e634e5f7ce8385bc3f1f4734e7e8e0d00a24ad4596a04f72f950c1ec84ec
7
- data.tar.gz: 6f2ed8e7febb6d6a9f9e36b3d86a5522230f7254bf46aaa4e966c9d60b91f71f80a8a2e46de9510f77bedbda53d759426d087c71fdecbb15c2ef2c1efdb08298
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.2.2'
5
+ VERSION = '1.3.0'
6
6
  end
7
7
  end
@@ -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.2.2
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