legion-settings 1.3.26 → 1.3.27
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/.gitignore +2 -1
- data/.pre-commit-config.yaml +29 -0
- data/CHANGELOG.md +14 -0
- data/README.md +46 -1
- data/lib/legion/settings/agent_loader.rb +2 -10
- data/lib/legion/settings/loader.rb +21 -35
- data/lib/legion/settings/project_env.rb +3 -11
- data/lib/legion/settings/resolver.rb +15 -27
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +137 -4
- data/scripts/pre-commit-rubocop.sh +39 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a096c46e4b074a4e77c104f21b1dc89b953ad15370e514ccd7face8cf8a7b8f
|
|
4
|
+
data.tar.gz: 06475bdc9bf41c6219e2c61fef17006346bb57b833a9a6c992a02f1d1178047a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 30cc986020bd6c2f4c783193f65fb07b935e08ddadd1a00608a90001a8312da19e88612e99449bc283ae4d716d119f22517c37809670f50e854a8658502c645f
|
|
7
|
+
data.tar.gz: 7d41547be02a86b3afc065e8865dbe16c02ebde7b592bd7f9a83585b22d7f07cfc1fe078eb171b754f2398d9fce9942410e8b9b999e744a73ee6abf378ada1bb
|
data/.gitignore
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Standard LegionIO pre-commit configuration
|
|
2
|
+
# Install: pre-commit install
|
|
3
|
+
# Manual: pre-commit run --all-files
|
|
4
|
+
repos:
|
|
5
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
6
|
+
rev: v5.0.0
|
|
7
|
+
hooks:
|
|
8
|
+
- id: trailing-whitespace
|
|
9
|
+
- id: end-of-file-fixer
|
|
10
|
+
- id: check-yaml
|
|
11
|
+
- id: check-json
|
|
12
|
+
exclude: Gemfile\.lock
|
|
13
|
+
- id: check-merge-conflict
|
|
14
|
+
|
|
15
|
+
- repo: local
|
|
16
|
+
hooks:
|
|
17
|
+
- id: rubocop
|
|
18
|
+
name: RuboCop (autofix)
|
|
19
|
+
entry: scripts/pre-commit-rubocop.sh
|
|
20
|
+
language: script
|
|
21
|
+
types: [ruby]
|
|
22
|
+
pass_filenames: true
|
|
23
|
+
|
|
24
|
+
- id: ruby-syntax
|
|
25
|
+
name: Ruby syntax check
|
|
26
|
+
entry: ruby -c
|
|
27
|
+
language: system
|
|
28
|
+
types: [ruby]
|
|
29
|
+
pass_filenames: true
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Legion::Settings Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.27] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Settings.reload!` — re-reads all previously loaded config files and re-resolves vault://, env://, and lease:// references; returns a hash of changed keys with old/new values; thread-safe via internal mutex
|
|
7
|
+
- `Settings.watch!` — registers a SIGHUP handler that triggers `reload!` in a background thread; optionally accepts a block for change notification
|
|
8
|
+
- `Settings.on_reload(&block)` — register callbacks invoked after `reload!` detects changes; multiple callbacks supported, called in order, rescue-safe
|
|
9
|
+
- Private `diff_settings` helper for deep comparison of old vs new config hashes
|
|
10
|
+
- Private `fire_reload_callbacks` for executing registered change callbacks
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `reload!` preserves programmatic module merges and reapplies `.legionio.env` overrides to the reloaded settings loader
|
|
14
|
+
- `watch!` no-ops when SIGHUP is unavailable and coalesces repeated SIGHUP events through a single reload worker
|
|
15
|
+
- Replaced deprecated helper logging method calls with direct `log.debug/info/warn/error` usage
|
|
16
|
+
|
|
3
17
|
## [1.3.26] - 2026-04-02
|
|
4
18
|
|
|
5
19
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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.
|
|
4
4
|
|
|
5
|
-
**Version**: 1.3.
|
|
5
|
+
**Version**: 1.3.27
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -44,6 +44,51 @@ If a caller wants the canonical Legion search directories, use `Legion::Settings
|
|
|
44
44
|
|
|
45
45
|
Each Legion module registers its own defaults via `merge_settings` during startup, and the nearest `.legionio.env` file is merged on top of base settings. Request overlays applied through `with_overlay` take highest precedence.
|
|
46
46
|
|
|
47
|
+
### Hot Reload
|
|
48
|
+
|
|
49
|
+
`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
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
changes = Legion::Settings.reload!
|
|
53
|
+
|
|
54
|
+
changes
|
|
55
|
+
# {
|
|
56
|
+
# "llm.default_model" => { old: "old-model", new: "new-model" }
|
|
57
|
+
# }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Callbacks run only when changes are detected:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Legion::Settings.on_reload do |changes|
|
|
64
|
+
Legion::Settings.logger.info("Settings changed: #{changes.keys.join(', ')}")
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`watch!` installs a SIGHUP handler when the platform supports it. Repeated signals are coalesced through one background reload worker, so rapid SIGHUP bursts do not create unbounded reload threads.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Legion::Settings.watch! do |changes|
|
|
72
|
+
Legion::Settings.logger.info("Reloaded #{changes.size} setting(s)")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Later, from a shell:
|
|
76
|
+
# kill -HUP <daemon_pid>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On platforms without `HUP`, `watch!` logs and returns without raising. Direct `reload!` remains available for API endpoints, tests, or environments that use a different process-control mechanism.
|
|
80
|
+
|
|
81
|
+
### Project Environment Overrides
|
|
82
|
+
|
|
83
|
+
When present, the nearest `.legionio.env` file is loaded after base settings and module defaults. Dot notation maps to nested settings:
|
|
84
|
+
|
|
85
|
+
```dotenv
|
|
86
|
+
llm.default_model=claude-sonnet
|
|
87
|
+
cache.driver=redis
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Hot reload picks up changes to this file as part of the same `reload!` flow.
|
|
91
|
+
|
|
47
92
|
### Secret Resolution
|
|
48
93
|
|
|
49
94
|
Settings values can reference external secret sources using URI syntax. Three schemes are supported:
|
|
@@ -20,7 +20,7 @@ module Legion
|
|
|
20
20
|
definition = load_file(path)
|
|
21
21
|
next unless definition && valid?(definition)
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
log.debug("Agent loaded: #{definition[:name]} (#{path})")
|
|
24
24
|
definition.merge(_source_path: path, _source_mtime: File.mtime(path))
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -32,7 +32,7 @@ module Legion
|
|
|
32
32
|
when '.json' then ::JSON.parse(content, symbolize_names: true)
|
|
33
33
|
end
|
|
34
34
|
rescue StandardError => e
|
|
35
|
-
|
|
35
|
+
log.warn("Failed to parse agent file #{path}: #{e.message}")
|
|
36
36
|
nil
|
|
37
37
|
end
|
|
38
38
|
|
|
@@ -51,14 +51,6 @@ module Legion
|
|
|
51
51
|
raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
|
|
52
52
|
raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
|
|
53
53
|
end
|
|
54
|
-
|
|
55
|
-
def log_debug(message)
|
|
56
|
-
log.debug(message)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def log_warn(message)
|
|
60
|
-
log.warn(message)
|
|
61
|
-
end
|
|
62
54
|
end
|
|
63
55
|
end
|
|
64
56
|
end
|
|
@@ -15,7 +15,7 @@ module Legion
|
|
|
15
15
|
include Legion::Logging::Helper
|
|
16
16
|
|
|
17
17
|
class Error < RuntimeError; end
|
|
18
|
-
attr_reader :warnings, :errors, :loaded_files, :settings
|
|
18
|
+
attr_reader :warnings, :errors, :loaded_files, :settings, :merged_modules
|
|
19
19
|
|
|
20
20
|
def self.default_directories
|
|
21
21
|
env_dirs = ENV.fetch('LEGION_SETTINGS_DIRS', nil)
|
|
@@ -40,6 +40,7 @@ module Legion
|
|
|
40
40
|
@settings = default_settings
|
|
41
41
|
@indifferent_access = false
|
|
42
42
|
@loaded_files = []
|
|
43
|
+
@merged_modules = {}
|
|
43
44
|
log.debug('Initialized Legion::Settings::Loader with default settings')
|
|
44
45
|
end
|
|
45
46
|
|
|
@@ -221,20 +222,21 @@ module Legion
|
|
|
221
222
|
|
|
222
223
|
def load_module_settings(config)
|
|
223
224
|
mod_name = config.keys.first
|
|
224
|
-
|
|
225
|
+
log.debug("Loading module settings: #{mod_name}")
|
|
226
|
+
@merged_modules = deep_merge(@merged_modules, config)
|
|
225
227
|
@settings = deep_merge(config, @settings)
|
|
226
228
|
mark_dirty!
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
def load_module_default(config)
|
|
230
232
|
mod_name = config.keys.first
|
|
231
|
-
|
|
233
|
+
log.debug("Loading module defaults: #{mod_name}")
|
|
232
234
|
@settings = deep_merge(config, @settings)
|
|
233
235
|
mark_dirty!
|
|
234
236
|
end
|
|
235
237
|
|
|
236
238
|
def load_file(file)
|
|
237
|
-
|
|
239
|
+
log.debug("Trying to load file #{file}")
|
|
238
240
|
if File.file?(file) && File.readable?(file)
|
|
239
241
|
begin
|
|
240
242
|
contents = read_config_file(file)
|
|
@@ -244,11 +246,11 @@ module Legion
|
|
|
244
246
|
@loaded_files << file
|
|
245
247
|
log.debug("Loaded settings file #{file}")
|
|
246
248
|
rescue Legion::JSON::ParseError => e
|
|
247
|
-
|
|
248
|
-
|
|
249
|
+
log.error("config file must be valid json: #{file}")
|
|
250
|
+
log.error(" parse error: #{e.message}")
|
|
249
251
|
end
|
|
250
252
|
else
|
|
251
|
-
|
|
253
|
+
log.warn("Config file does not exist or is not readable file:#{file}")
|
|
252
254
|
end
|
|
253
255
|
end
|
|
254
256
|
|
|
@@ -257,7 +259,7 @@ module Legion
|
|
|
257
259
|
if File.readable?(path) && File.executable?(path)
|
|
258
260
|
files = Dir.glob(File.join(path, '**', '*.json'))
|
|
259
261
|
files.each { |file| load_file(file) }
|
|
260
|
-
|
|
262
|
+
log.info("Settings: loaded directory #{path} (#{files.size} files)")
|
|
261
263
|
else
|
|
262
264
|
load_error('insufficient permissions for loading', directory: directory)
|
|
263
265
|
end
|
|
@@ -270,7 +272,7 @@ module Legion
|
|
|
270
272
|
@settings[:client][:subscriptions].uniq!
|
|
271
273
|
mark_dirty!
|
|
272
274
|
else
|
|
273
|
-
|
|
275
|
+
log.warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
|
|
274
276
|
end
|
|
275
277
|
end
|
|
276
278
|
|
|
@@ -302,7 +304,7 @@ module Legion
|
|
|
302
304
|
end
|
|
303
305
|
|
|
304
306
|
def load_dns_first_boot(bootstrap)
|
|
305
|
-
|
|
307
|
+
log.debug("DNS bootstrap: first boot, fetching from #{bootstrap.url}")
|
|
306
308
|
config = bootstrap.fetch
|
|
307
309
|
bootstrap.write_cache(config) if config
|
|
308
310
|
config
|
|
@@ -324,7 +326,7 @@ module Legion
|
|
|
324
326
|
fresh = bootstrap.fetch
|
|
325
327
|
bootstrap.write_cache(fresh) if fresh
|
|
326
328
|
rescue StandardError => e
|
|
327
|
-
|
|
329
|
+
log.warn("DNS background refresh failed: #{e.message}")
|
|
328
330
|
end
|
|
329
331
|
end
|
|
330
332
|
|
|
@@ -361,7 +363,7 @@ module Legion
|
|
|
361
363
|
|
|
362
364
|
@settings[:api] ||= {}
|
|
363
365
|
@settings[:api][:port] = ENV['LEGION_API_PORT'].to_i
|
|
364
|
-
|
|
366
|
+
log.warn("using api port environment variable, api: #{@settings[:api]}")
|
|
365
367
|
mark_dirty!
|
|
366
368
|
end
|
|
367
369
|
|
|
@@ -423,7 +425,7 @@ module Legion
|
|
|
423
425
|
def system_hostname
|
|
424
426
|
Socket.gethostname
|
|
425
427
|
rescue StandardError => e
|
|
426
|
-
|
|
428
|
+
log.debug("Legion::Settings::Loader#system_hostname failed: #{e.message}")
|
|
427
429
|
'unknown'
|
|
428
430
|
end
|
|
429
431
|
|
|
@@ -432,7 +434,7 @@ module Legion
|
|
|
432
434
|
preferred = addresses.find { |a| rfc1918?(a.ip_address) }
|
|
433
435
|
(preferred || addresses.first)&.ip_address || 'unknown'
|
|
434
436
|
rescue StandardError => e
|
|
435
|
-
|
|
437
|
+
log.debug("Legion::Settings::Loader#system_address failed: #{e.message}")
|
|
436
438
|
'unknown'
|
|
437
439
|
end
|
|
438
440
|
|
|
@@ -442,34 +444,18 @@ module Legion
|
|
|
442
444
|
ip.start_with?('192.168.')
|
|
443
445
|
end
|
|
444
446
|
|
|
445
|
-
def log_info(message)
|
|
446
|
-
log.info(message)
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
def log_debug(message)
|
|
450
|
-
log.debug(message)
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def log_warn(message)
|
|
454
|
-
log.warn(message)
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
def log_error(message)
|
|
458
|
-
log.error(message)
|
|
459
|
-
end
|
|
460
|
-
|
|
461
447
|
def warning(message, data = {})
|
|
462
448
|
@warnings << {
|
|
463
449
|
message: message
|
|
464
450
|
}.merge(data)
|
|
465
|
-
|
|
451
|
+
log.warn(message)
|
|
466
452
|
end
|
|
467
453
|
|
|
468
454
|
def load_error(message, data = {})
|
|
469
455
|
@errors << {
|
|
470
456
|
message: message
|
|
471
457
|
}.merge(data)
|
|
472
|
-
|
|
458
|
+
log.error(message)
|
|
473
459
|
raise(Error, message)
|
|
474
460
|
end
|
|
475
461
|
|
|
@@ -480,7 +466,7 @@ module Legion
|
|
|
480
466
|
nameservers: config[:nameserver]&.map(&:to_s)&.uniq
|
|
481
467
|
}
|
|
482
468
|
rescue StandardError => e
|
|
483
|
-
|
|
469
|
+
log.warn("Failed to read resolv config: #{e.message}")
|
|
484
470
|
{ search_domains: [], nameservers: [] }
|
|
485
471
|
end
|
|
486
472
|
|
|
@@ -491,10 +477,10 @@ module Legion
|
|
|
491
477
|
|
|
492
478
|
fqdn.include?('.') ? fqdn : nil
|
|
493
479
|
rescue Timeout::Error
|
|
494
|
-
|
|
480
|
+
log.debug('FQDN detection skipped (DNS timeout)')
|
|
495
481
|
nil
|
|
496
482
|
rescue StandardError => e
|
|
497
|
-
|
|
483
|
+
log.debug("FQDN detection skipped (#{e.message.split(':').first})")
|
|
498
484
|
nil
|
|
499
485
|
end
|
|
500
486
|
end
|
|
@@ -57,14 +57,14 @@ module Legion
|
|
|
57
57
|
|
|
58
58
|
parts = line.split('=', 2)
|
|
59
59
|
unless parts.length == 2
|
|
60
|
-
|
|
60
|
+
log.warn("#{path}:#{idx + 1}: skipping malformed line (no '=' found)")
|
|
61
61
|
next
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
raw_key, value = parts
|
|
65
65
|
key_parts = raw_key.strip.split('.')
|
|
66
66
|
if key_parts.empty? || key_parts.any?(&:empty?)
|
|
67
|
-
|
|
67
|
+
log.warn("#{path}:#{idx + 1}: skipping invalid key '#{raw_key.strip}'")
|
|
68
68
|
next
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -85,7 +85,7 @@ module Legion
|
|
|
85
85
|
|
|
86
86
|
overrides = parse_env_file(path)
|
|
87
87
|
deep_merge_into!(settings, overrides)
|
|
88
|
-
|
|
88
|
+
log.debug("ProjectEnv: loaded #{path}")
|
|
89
89
|
path
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -115,14 +115,6 @@ module Legion
|
|
|
115
115
|
end
|
|
116
116
|
base
|
|
117
117
|
end
|
|
118
|
-
|
|
119
|
-
def log_debug(message)
|
|
120
|
-
log.debug(message)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def log_warn(message)
|
|
124
|
-
log.warn(message)
|
|
125
|
-
end
|
|
126
118
|
end
|
|
127
119
|
end
|
|
128
120
|
end
|
|
@@ -21,10 +21,10 @@ module Legion
|
|
|
21
21
|
@vault_cache = {}
|
|
22
22
|
|
|
23
23
|
vault_count = count_vault_refs(settings_hash)
|
|
24
|
-
|
|
24
|
+
log.warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
|
|
25
25
|
|
|
26
26
|
lease_count = count_lease_refs(settings_hash)
|
|
27
|
-
|
|
27
|
+
log.warn("LeaseManager not available — #{lease_count} lease:// reference(s) will not be resolved") if lease_count.positive? && !lease_manager_available?
|
|
28
28
|
|
|
29
29
|
resolved = 0
|
|
30
30
|
unresolved = 0
|
|
@@ -36,7 +36,7 @@ module Legion
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
log.info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
|
|
40
40
|
|
|
41
41
|
settings_hash
|
|
42
42
|
end
|
|
@@ -101,7 +101,7 @@ module Legion
|
|
|
101
101
|
Legion::Settings[:crypt][:vault][:connected] == true ||
|
|
102
102
|
(Legion::Crypt.respond_to?(:connected_clusters) && Legion::Crypt.connected_clusters.any?)
|
|
103
103
|
rescue StandardError => e
|
|
104
|
-
|
|
104
|
+
log.debug("Legion::Settings::Resolver#vault_connected? failed: #{e.message}")
|
|
105
105
|
false
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -136,7 +136,7 @@ module Legion
|
|
|
136
136
|
|
|
137
137
|
resolved = resolve_single(value)
|
|
138
138
|
if resolved.nil?
|
|
139
|
-
|
|
139
|
+
log.warn("Settings resolver: could not resolve #{current_path} (#{value})")
|
|
140
140
|
yield(:unresolved) if block_given?
|
|
141
141
|
else
|
|
142
142
|
container[key] = resolved
|
|
@@ -149,7 +149,7 @@ module Legion
|
|
|
149
149
|
if resolvable_chain?(value) && value.all? { |entry| !entry.is_a?(Hash) && !entry.is_a?(Array) }
|
|
150
150
|
resolved = resolve_chain(value)
|
|
151
151
|
if resolved.nil?
|
|
152
|
-
|
|
152
|
+
log.warn("Settings resolver: fallback chain exhausted for #{current_path}")
|
|
153
153
|
yield(:unresolved) if block_given?
|
|
154
154
|
else
|
|
155
155
|
container[key] = resolved
|
|
@@ -162,27 +162,27 @@ module Legion
|
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
def resolve_vault(path, key)
|
|
165
|
-
|
|
165
|
+
log.debug("resolve_vault: path=#{path}, key=#{key}, vault_available=#{@vault_available}")
|
|
166
166
|
return nil unless @vault_available
|
|
167
167
|
|
|
168
168
|
@vault_cache[path] ||= begin
|
|
169
|
-
|
|
169
|
+
log.debug("resolve_vault: calling Legion::Crypt.read(#{path.inspect})")
|
|
170
170
|
result = Legion::Crypt.read(path)
|
|
171
|
-
|
|
171
|
+
log.debug("resolve_vault: read returned #{result.nil? ? 'nil' : "keys=#{result.keys.inspect}"}")
|
|
172
172
|
result
|
|
173
173
|
rescue StandardError => e
|
|
174
|
-
|
|
174
|
+
log.warn("Settings resolver: vault read failed for #{path}: #{e.class}=#{e.message}")
|
|
175
175
|
nil
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
data = @vault_cache[path]
|
|
179
179
|
unless data.is_a?(Hash)
|
|
180
|
-
|
|
180
|
+
log.debug("resolve_vault: data at #{path} is #{data.class}, returning nil")
|
|
181
181
|
return nil
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
value = data[key.to_sym] || data[key.to_s]
|
|
185
|
-
|
|
185
|
+
log.debug("resolve_vault: #{path}##{key} = #{value.nil? ? 'nil' : '<present>'}")
|
|
186
186
|
value
|
|
187
187
|
end
|
|
188
188
|
|
|
@@ -191,14 +191,14 @@ module Legion
|
|
|
191
191
|
|
|
192
192
|
Legion::Crypt::LeaseManager.instance.fetch(name, key)
|
|
193
193
|
rescue StandardError => e
|
|
194
|
-
|
|
194
|
+
log.debug("Settings resolver: lease fetch failed for #{name}##{key}: #{e.message}")
|
|
195
195
|
nil
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
def lease_manager_available?
|
|
199
199
|
defined?(Legion::Crypt::LeaseManager)
|
|
200
200
|
rescue StandardError => e
|
|
201
|
-
|
|
201
|
+
log.debug("Legion::Settings::Resolver#lease_manager_available? failed: #{e.message}")
|
|
202
202
|
false
|
|
203
203
|
end
|
|
204
204
|
|
|
@@ -215,7 +215,7 @@ module Legion
|
|
|
215
215
|
path_parts = path_string.split('.').map(&:to_sym)
|
|
216
216
|
Legion::Crypt::LeaseManager.instance.register_ref(m[1], m[2], path_parts)
|
|
217
217
|
rescue StandardError => e
|
|
218
|
-
|
|
218
|
+
log.debug("Legion::Settings::Resolver#register_lease_ref failed for #{path_string}: #{e.message}")
|
|
219
219
|
nil
|
|
220
220
|
end
|
|
221
221
|
|
|
@@ -242,18 +242,6 @@ module Legion
|
|
|
242
242
|
raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
|
|
243
243
|
raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
|
|
244
244
|
end
|
|
245
|
-
|
|
246
|
-
def log_info(message)
|
|
247
|
-
log.info(message)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def log_warn(message)
|
|
251
|
-
log.warn(message)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def log_debug(message)
|
|
255
|
-
log.debug(message)
|
|
256
|
-
end
|
|
257
245
|
end
|
|
258
246
|
end
|
|
259
247
|
end
|
data/lib/legion/settings.rb
CHANGED
|
@@ -125,10 +125,10 @@ module Legion
|
|
|
125
125
|
#
|
|
126
126
|
# @param start_dir [String, nil] directory to start searching from (defaults to Dir.pwd)
|
|
127
127
|
# @return [String, nil] path to the loaded file, or nil if none found
|
|
128
|
-
def load_project_env(start_dir: nil)
|
|
129
|
-
ensure_loader
|
|
130
|
-
path = ProjectEnv.load_into(
|
|
131
|
-
|
|
128
|
+
def load_project_env(start_dir: nil, loader: nil)
|
|
129
|
+
target_loader = loader || ensure_loader
|
|
130
|
+
path = ProjectEnv.load_into(target_loader.settings, start_dir: start_dir)
|
|
131
|
+
target_loader.mark_dirty! if path
|
|
132
132
|
path
|
|
133
133
|
end
|
|
134
134
|
|
|
@@ -184,11 +184,118 @@ module Legion
|
|
|
184
184
|
@loader.errors
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Hot-reload: re-read all previously loaded config files, re-resolve
|
|
189
|
+
# vault:// / env:// / lease:// references, and notify registered
|
|
190
|
+
# callbacks of changed keys.
|
|
191
|
+
#
|
|
192
|
+
# Safe to call from a SIGHUP handler or API endpoint.
|
|
193
|
+
#
|
|
194
|
+
# @return [Hash] changed keys { key => { old: ..., new: ... } }
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
def reload!
|
|
197
|
+
@reload_mutex ||= Mutex.new
|
|
198
|
+
@reload_mutex.synchronize do
|
|
199
|
+
return {} unless @loader
|
|
200
|
+
|
|
201
|
+
old_hash = @loader.to_hash.dup
|
|
202
|
+
files = @loader.loaded_files.dup
|
|
203
|
+
|
|
204
|
+
# Re-create loader and replay the same files
|
|
205
|
+
new_loader = Legion::Settings::Loader.new
|
|
206
|
+
new_loader.load_env
|
|
207
|
+
new_loader.load_dns_bootstrap
|
|
208
|
+
files.each { |f| new_loader.load_file(f) if File.exist?(f) }
|
|
209
|
+
|
|
210
|
+
# Replay module merges so extension defaults are preserved
|
|
211
|
+
if @loader.respond_to?(:merged_modules)
|
|
212
|
+
@loader.merged_modules.each do |mod_key, mod_defaults|
|
|
213
|
+
new_loader.load_module_settings(mod_key => mod_defaults)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Replay project env overrides (.legionio.env)
|
|
218
|
+
load_project_env(loader: new_loader)
|
|
219
|
+
|
|
220
|
+
# Re-resolve secrets (vault://, env://, lease://)
|
|
221
|
+
begin
|
|
222
|
+
require 'legion/settings/resolver'
|
|
223
|
+
Resolver.resolve_secrets!(new_loader.to_hash)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
logger.warn("Settings reload: secret resolution failed: #{e.message}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
new_hash = new_loader.to_hash
|
|
229
|
+
changes = diff_settings(old_hash, new_hash)
|
|
230
|
+
|
|
231
|
+
if changes.empty?
|
|
232
|
+
logger.info('Settings reload: no changes detected')
|
|
233
|
+
else
|
|
234
|
+
@loader = new_loader
|
|
235
|
+
logger.info("Settings reload: #{changes.size} key(s) changed — #{changes.keys.join(', ')}")
|
|
236
|
+
fire_reload_callbacks(changes)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
changes
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Register a SIGHUP handler that triggers reload!
|
|
244
|
+
# Optionally accepts a block that will be called with the changes hash
|
|
245
|
+
# after each successful reload.
|
|
246
|
+
#
|
|
247
|
+
# @yield [changes] optional callback receiving the changes hash
|
|
248
|
+
def watch!(&block)
|
|
249
|
+
on_reload(&block) if block
|
|
250
|
+
|
|
251
|
+
unless Signal.list.key?('HUP')
|
|
252
|
+
logger.info('Settings: SIGHUP not available on this platform — watch! is a no-op')
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Single coalescing worker thread: SIGHUP sets the flag, worker drains it.
|
|
257
|
+
@reload_flag ||= Queue.new
|
|
258
|
+
@reload_worker ||= Thread.new do
|
|
259
|
+
loop do
|
|
260
|
+
@reload_flag.pop # blocks until signalled
|
|
261
|
+
# Drain any queued signals so rapid SIGHUPs collapse into one reload
|
|
262
|
+
@reload_flag.pop until @reload_flag.empty?
|
|
263
|
+
logger.info('Settings: SIGHUP received — reloading configuration')
|
|
264
|
+
reload!
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
logger.error("Settings: reload after SIGHUP failed: #{e.message}")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
trap('HUP') { @reload_flag << :reload }
|
|
271
|
+
logger.info('Settings: SIGHUP handler registered for config hot-reload')
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Register a callback to be invoked after reload! detects changes.
|
|
275
|
+
# Multiple callbacks can be registered; they are called in order.
|
|
276
|
+
#
|
|
277
|
+
# @yield [changes] the changes hash { key => { old: ..., new: ... } }
|
|
278
|
+
def on_reload(&block)
|
|
279
|
+
raise ArgumentError, 'on_reload requires a block' unless block
|
|
280
|
+
|
|
281
|
+
@reload_callbacks ||= []
|
|
282
|
+
@reload_callbacks << block
|
|
283
|
+
end
|
|
284
|
+
|
|
187
285
|
def reset!
|
|
286
|
+
if @reload_worker&.alive? && @reload_worker != Thread.current
|
|
287
|
+
@reload_worker.kill
|
|
288
|
+
@reload_worker.join
|
|
289
|
+
end
|
|
290
|
+
|
|
188
291
|
@loader = nil
|
|
189
292
|
@loaded = nil
|
|
190
293
|
@schema = nil
|
|
191
294
|
@cross_validations = nil
|
|
295
|
+
@reload_callbacks = nil
|
|
296
|
+
@reload_mutex = nil
|
|
297
|
+
@reload_flag = nil
|
|
298
|
+
@reload_worker = nil
|
|
192
299
|
Overlay.clear_overlay!
|
|
193
300
|
end
|
|
194
301
|
|
|
@@ -274,6 +381,32 @@ module Legion
|
|
|
274
381
|
@loader.errors << w
|
|
275
382
|
end
|
|
276
383
|
end
|
|
384
|
+
|
|
385
|
+
def diff_settings(old_hash, new_hash, prefix = '')
|
|
386
|
+
changes = {}
|
|
387
|
+
all_keys = (old_hash.keys + new_hash.keys).uniq
|
|
388
|
+
all_keys.each do |key|
|
|
389
|
+
full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
390
|
+
old_val = old_hash[key]
|
|
391
|
+
new_val = new_hash[key]
|
|
392
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
393
|
+
changes.merge!(diff_settings(old_val, new_val, full_key))
|
|
394
|
+
elsif old_val != new_val
|
|
395
|
+
changes[full_key] = { old: old_val, new: new_val }
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
changes
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def fire_reload_callbacks(changes)
|
|
402
|
+
return unless @reload_callbacks&.any?
|
|
403
|
+
|
|
404
|
+
@reload_callbacks.each do |cb|
|
|
405
|
+
cb.call(changes)
|
|
406
|
+
rescue StandardError => e
|
|
407
|
+
logger.warn("Settings reload callback failed: #{e.message}")
|
|
408
|
+
end
|
|
409
|
+
end
|
|
277
410
|
end
|
|
278
411
|
end
|
|
279
412
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-commit hook: run RuboCop with autofix on staged Ruby files.
|
|
3
|
+
# Tries rubocop directly, then bundle exec. If the binary is truly
|
|
4
|
+
# unavailable (exit 127 / crash / Prism conflict), warns and defers
|
|
5
|
+
# to CI. If rubocop runs but reports offenses, fails the commit.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
run_rubocop() {
|
|
9
|
+
output=$("$@" -A --force-exclusion "${FILES[@]}" 2>&1)
|
|
10
|
+
rc=$?
|
|
11
|
+
if [ $rc -eq 0 ] || [ $rc -eq 1 ]; then
|
|
12
|
+
# rubocop ran successfully: 0 = clean, 1 = offenses found
|
|
13
|
+
echo "$output"
|
|
14
|
+
return $rc
|
|
15
|
+
fi
|
|
16
|
+
# exit > 1 means rubocop crashed / couldn't load
|
|
17
|
+
return 2
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
FILES=("$@")
|
|
21
|
+
|
|
22
|
+
if run_rubocop rubocop; then
|
|
23
|
+
exit 0
|
|
24
|
+
elif [ $? -eq 1 ]; then
|
|
25
|
+
echo "RuboCop found offenses that could not be auto-corrected."
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if run_rubocop bundle exec rubocop; then
|
|
30
|
+
exit 0
|
|
31
|
+
elif [ $? -eq 1 ]; then
|
|
32
|
+
echo "RuboCop found offenses that could not be auto-corrected."
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
echo "⚠ RuboCop not available locally (Prism conflict?) — CI will enforce."
|
|
37
|
+
echo " Run 'ruby -c' to at least verify syntax."
|
|
38
|
+
ruby -c "$@" 2>&1 || exit 1
|
|
39
|
+
exit 0
|
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.3.
|
|
4
|
+
version: 1.3.27
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -52,6 +52,7 @@ files:
|
|
|
52
52
|
- ".github/dependabot.yml"
|
|
53
53
|
- ".github/workflows/ci.yml"
|
|
54
54
|
- ".gitignore"
|
|
55
|
+
- ".pre-commit-config.yaml"
|
|
55
56
|
- ".rubocop.yml"
|
|
56
57
|
- CHANGELOG.md
|
|
57
58
|
- CLAUDE.md
|
|
@@ -75,6 +76,7 @@ files:
|
|
|
75
76
|
- lib/legion/settings/validation_error.rb
|
|
76
77
|
- lib/legion/settings/validators/tls.rb
|
|
77
78
|
- lib/legion/settings/version.rb
|
|
79
|
+
- scripts/pre-commit-rubocop.sh
|
|
78
80
|
- sonar-project.properties
|
|
79
81
|
homepage: https://github.com/LegionIO/legion-settings
|
|
80
82
|
licenses:
|