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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4d1e8b1f3f0f96ee8db9d11604d914fc253e430827e37f7e30cc244151dc75b
4
- data.tar.gz: 1ed13e9f16b97fc33acc72e484671794210ac4750aef5502fc86e685ec2f9324
3
+ metadata.gz: 6a096c46e4b074a4e77c104f21b1dc89b953ad15370e514ccd7face8cf8a7b8f
4
+ data.tar.gz: 06475bdc9bf41c6219e2c61fef17006346bb57b833a9a6c992a02f1d1178047a
5
5
  SHA512:
6
- metadata.gz: 47ab18a67eef8c35d86e52e9deec5716916754e5a84e0faec1ba3bbed4f24e9f4bb5c7749268eee23c96056ce85ef11c47787ac2df06fa5b918365730ddf6926
7
- data.tar.gz: c76877c9e9a19f44c33a816a0580c265f721fe6dd0ef486eb06ec377ac6aedd5ae60fe52b4aaf4d8feb59bb18527f6127da40a0611d27718de3be63af5581b97
6
+ metadata.gz: 30cc986020bd6c2f4c783193f65fb07b935e08ddadd1a00608a90001a8312da19e88612e99449bc283ae4d716d119f22517c37809670f50e854a8658502c645f
7
+ data.tar.gz: 7d41547be02a86b3afc065e8865dbe16c02ebde7b592bd7f9a83585b22d7f07cfc1fe078eb171b754f2398d9fce9942410e8b9b999e744a73ee6abf378ada1bb
data/.gitignore CHANGED
@@ -9,7 +9,8 @@
9
9
  /tmp/
10
10
  /legion/.idea/
11
11
  /.idea/
12
+ *.gem
12
13
  *.key
13
14
  # rspec failure tracking
14
15
  .rspec_status
15
- legionio.key
16
+ legionio.key
@@ -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.26
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
- log_debug("Agent loaded: #{definition[:name]} (#{path})")
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
- log_warn("Failed to parse agent file #{path}: #{e.message}")
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
- log_debug("Loading module settings: #{mod_name}")
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
- log_debug("Loading module defaults: #{mod_name}")
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
- log_debug("Trying to load file #{file}")
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
- log_error("config file must be valid json: #{file}")
248
- log_error(" parse error: #{e.message}")
249
+ log.error("config file must be valid json: #{file}")
250
+ log.error(" parse error: #{e.message}")
249
251
  end
250
252
  else
251
- log_warn("Config file does not exist or is not readable file:#{file}")
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
- log_info("Settings: loaded directory #{path} (#{files.size} files)")
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
- log_warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
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
- log_debug("DNS bootstrap: first boot, fetching from #{bootstrap.url}")
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
- log_warn("DNS background refresh failed: #{e.message}")
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
- log_warn("using api port environment variable, api: #{@settings[:api]}")
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
- log_debug("Legion::Settings::Loader#system_hostname failed: #{e.message}")
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
- log_debug("Legion::Settings::Loader#system_address failed: #{e.message}")
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
- log_warn(message)
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
- log_error(message)
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
- log_warn("Failed to read resolv config: #{e.message}")
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
- log_debug('FQDN detection skipped (DNS timeout)')
480
+ log.debug('FQDN detection skipped (DNS timeout)')
495
481
  nil
496
482
  rescue StandardError => e
497
- log_debug("FQDN detection skipped (#{e.message.split(':').first})")
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
- log_warn("#{path}:#{idx + 1}: skipping malformed line (no '=' found)")
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
- log_warn("#{path}:#{idx + 1}: skipping invalid key '#{raw_key.strip}'")
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
- log_debug("ProjectEnv: loaded #{path}")
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
- log_warn("Vault not connected — #{vault_count} vault:// reference(s) will not be resolved") if vault_count.positive? && !@vault_available
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
- log_warn("LeaseManager not available — #{lease_count} lease:// reference(s) will not be resolved") if lease_count.positive? && !lease_manager_available?
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
- log_info("Settings resolver: #{resolved} resolved, #{unresolved} unresolved") if resolved.positive? || unresolved.positive?
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
- log_debug("Legion::Settings::Resolver#vault_connected? failed: #{e.message}")
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
- log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
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
- log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
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
- log_debug("resolve_vault: path=#{path}, key=#{key}, vault_available=#{@vault_available}")
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
- log_debug("resolve_vault: calling Legion::Crypt.read(#{path.inspect})")
169
+ log.debug("resolve_vault: calling Legion::Crypt.read(#{path.inspect})")
170
170
  result = Legion::Crypt.read(path)
171
- log_debug("resolve_vault: read returned #{result.nil? ? 'nil' : "keys=#{result.keys.inspect}"}")
171
+ log.debug("resolve_vault: read returned #{result.nil? ? 'nil' : "keys=#{result.keys.inspect}"}")
172
172
  result
173
173
  rescue StandardError => e
174
- log_warn("Settings resolver: vault read failed for #{path}: #{e.class}=#{e.message}")
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
- log_debug("resolve_vault: data at #{path} is #{data.class}, returning nil")
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
- log_debug("resolve_vault: #{path}##{key} = #{value.nil? ? 'nil' : '<present>'}")
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
- log_debug("Settings resolver: lease fetch failed for #{name}##{key}: #{e.message}")
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
- log_debug("Legion::Settings::Resolver#lease_manager_available? failed: #{e.message}")
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
- log_debug("Legion::Settings::Resolver#register_lease_ref failed for #{path_string}: #{e.message}")
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.3.26'
5
+ VERSION = '1.3.27'
6
6
  end
7
7
  end
@@ -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(@loader.settings, start_dir: start_dir)
131
- @loader.mark_dirty! if path
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.26
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: