legion-settings 1.3.24 → 1.3.26

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: 482b26bda825959da123ce4b68f0503181bbc05a2af3bbbbc912d067e76ae2cb
4
- data.tar.gz: 45c70811a101925a22ec51244b6f8e42e1ad9d8cacc02eb8374c56eb11b6a9da
3
+ metadata.gz: a4d1e8b1f3f0f96ee8db9d11604d914fc253e430827e37f7e30cc244151dc75b
4
+ data.tar.gz: 1ed13e9f16b97fc33acc72e484671794210ac4750aef5502fc86e685ec2f9324
5
5
  SHA512:
6
- metadata.gz: b063262f1a803400f2d86fbdbfe62d7778f9c2c8f01bd78baf73d74216b25417c4ae53791cacced67024bad384d4e3d3fab5239b729d568f636496f6a5b43170
7
- data.tar.gz: b8d8c69b34ef0688e2d82b7ad88a175c59ee2dfb48f64267fa409ad26b29ae7394213f0947a6da73c07479f5df65390f75e15f602957eab031aeee14be261c59
6
+ metadata.gz: 47ab18a67eef8c35d86e52e9deec5716916754e5a84e0faec1ba3bbed4f24e9f4bb5c7749268eee23c96056ce85ef11c47787ac2df06fa5b918365730ddf6926
7
+ data.tar.gz: c76877c9e9a19f44c33a816a0580c265f721fe6dd0ef486eb06ec377ac6aedd5ae60fe52b4aaf4d8feb59bb18527f6127da40a0611d27718de3be63af5581b97
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Legion::Settings Changelog
2
2
 
3
+ ## [1.3.26] - 2026-04-02
4
+
5
+ ### Changed
6
+ - Added a runtime dependency on `legion-logging >= 1.4.0` and moved `Settings`, `Loader`, `Resolver`, `ProjectEnv`, and `AgentLoader` onto component-aware logging helpers
7
+ - Added `Legion::Logging::Helper` integration to top-level `Settings` access and DNS bootstrap logging so component tags and per-component levels apply consistently
8
+ - `extensions.default_extension_settings` now defaults to `{}` instead of injecting a synthetic logger config
9
+ - `Legion::Settings::Helper#settings` now returns `{}` when an extension has no explicit settings
10
+ - `region.default_affinity` now defaults to `any`
11
+ - `[]`, `dig`, and no-arg `load` now share the same project-env-aware load path, and the README now documents the actual `load`/`Loader.default_directories` split
12
+
13
+ ### Fixed
14
+ - `validate!` now clears stale validation errors before rebuilding state, and nested schema branches now fail when a hash-shaped setting is replaced with a scalar
15
+ - Loader mutation paths now invalidate indifferent-access and digest caches consistently, `load_module_default` no longer overwrites existing scalar settings, and resolver traversal now handles arrays of hashes
16
+ - `Resolver` now treats clustered Vault connectivity from `Legion::Crypt` as available for `vault://` resolution instead of relying only on the top-level `crypt.vault.connected` flag
17
+ - Logger-backed settings code paths now use consistent `log.debug/info/warn/error` behavior instead of mixed direct `Legion::Logging.*` calls
18
+
19
+ ## [1.3.25] - 2026-03-31
20
+
21
+ ### Added
22
+ - `Settings.with_overlay(overrides) { }` — thread-local request-scoped settings overlay for per-tenant LLM routing without node restarts; nestable, cleans up via ensure block (closes #9)
23
+ - `Settings.load_project_env(start_dir:)` — discovers and loads `.legionio.env` from Dir.pwd upward; dot-notation keys (`llm.default_model=haiku`) map to nested settings paths; auto-called during `Settings.load` (closes #10)
24
+ - `Legion::Settings::Overlay` module — thread-local overlay storage with `with_overlay`, `current_overlay`, `overlay_for`, `clear_overlay!`
25
+ - `Legion::Settings::ProjectEnv` module — env file discovery, parsing, and merging
26
+ - Resolution order: request overlay > project `.legionio.env` > global settings
27
+ - 44 new specs covering overlay scoping/nesting/cleanup, thread isolation, `.legionio.env` parsing, discovery, key mapping, and resolution order
28
+
3
29
  ## [1.3.24] - 2026-03-30
4
30
 
5
31
  ### Added
data/Gemfile CHANGED
@@ -10,5 +10,6 @@ group :test do
10
10
  gem 'rspec'
11
11
  gem 'rspec_junit_formatter'
12
12
  gem 'rubocop'
13
+ gem 'rubocop-legion'
13
14
  gem 'simplecov'
14
15
  end
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.22
5
+ **Version**: 1.3.26
6
6
 
7
7
  ## Installation
8
8
 
@@ -21,20 +21,28 @@ gem 'legion-settings'
21
21
  ```ruby
22
22
  require 'legion/settings'
23
23
 
24
- Legion::Settings.load(config_dir: './') # loads all .json files in the directory
24
+ Legion::Settings.load # loads defaults, env, DNS bootstrap, and nearest .legionio.env
25
+ Legion::Settings.load(config_dir: './settings') # also loads all .json files in the directory
25
26
 
26
27
  Legion::Settings[:client][:hostname]
27
- Legion::Settings[:transport][:connection][:host]
28
+ Legion::Settings.dig(:transport, :connection, :host)
28
29
  ```
29
30
 
30
- ### Config Paths (checked in order)
31
+ `[]` and `dig` will auto-load settings on first access, and implicit access follows the same overlay/project-env/base precedence as explicit `load`.
31
32
 
32
- 1. `/etc/legionio/`
33
- 2. `~/.legionio/settings/`
34
- 3. `~/legionio/`
35
- 4. `./settings/`
33
+ ### Config Loading
36
34
 
37
- Each Legion module registers its own defaults via `merge_settings` during startup.
35
+ `Legion::Settings.load` only consumes the paths you pass via `config_file`, `config_dir`, or `config_dirs`.
36
+
37
+ If a caller wants the canonical Legion search directories, use `Legion::Settings::Loader.default_directories`:
38
+
39
+ 1. `~/.legionio/settings`
40
+ 2. `/etc/legionio/settings` on Unix-like systems
41
+ 3. `%APPDATA%\\legionio\\settings` on Windows when `APPDATA` is present
42
+
43
+ `LegionIO` uses those directories during daemon boot. Library consumers can choose to pass any directory set they want.
44
+
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.
38
46
 
39
47
  ### Secret Resolution
40
48
 
@@ -76,25 +84,25 @@ Legion::Settings.validate! # raises ValidationError if any settings are invalid
76
84
 
77
85
  # In development, warn instead of raising:
78
86
  # Set LEGION_DEV=true or Legion::Settings.set_prop(:dev, true)
79
- # validate! will warn to $stderr (or Legion::Logging) instead of raising
87
+ # validate! will warn through the configured logger instead of raising
80
88
  ```
81
89
 
82
90
  ### Logging Defaults
83
91
 
84
- The `logging` key includes a `transport` sub-section (new in 1.3.22) that controls whether log events are forwarded over the message bus:
92
+ The `logging` key includes a `transport` sub-section that controls whether log events are forwarded over the message bus:
85
93
 
86
94
  ```json
87
95
  {
88
96
  "logging": {
89
97
  "level": "info",
90
98
  "format": "text",
91
- "log_file": null,
99
+ "log_file": "./legionio/logs/legion.log",
92
100
  "log_stdout": true,
93
101
  "trace": true,
94
102
  "async": true,
95
103
  "include_pid": false,
96
104
  "transport": {
97
- "enabled": false,
105
+ "enabled": true,
98
106
  "forward_logs": true,
99
107
  "forward_exceptions": true
100
108
  }
@@ -102,7 +110,7 @@ The `logging` key includes a `transport` sub-section (new in 1.3.22) that contro
102
110
  }
103
111
  ```
104
112
 
105
- When `transport.enabled` is `true`, log events and unhandled exceptions are published to the AMQP bus so a central log consumer can aggregate them. Disabled by default to avoid a dependency on `legion-transport` at boot.
113
+ When `transport.enabled` is `true`, log events and unhandled exceptions are published to the AMQP bus so a central log consumer can aggregate them.
106
114
 
107
115
  ## Requirements
108
116
 
@@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
27
27
  }
28
28
 
29
29
  spec.add_dependency 'legion-json', '>= 1.2.0'
30
+ spec.add_dependency 'legion-logging', '>= 1.5.0'
30
31
  end
@@ -2,10 +2,13 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'json'
5
+ require 'legion/logging'
5
6
 
6
7
  module Legion
7
8
  module Settings
8
9
  module AgentLoader
10
+ extend Legion::Logging::Helper
11
+
9
12
  EXTENSIONS = %w[.yaml .yml .json].freeze
10
13
  GLOB = '*.{yaml,yml,json}'
11
14
 
@@ -44,12 +47,17 @@ module Legion
44
47
 
45
48
  private
46
49
 
50
+ def resolve_logger_settings
51
+ raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
52
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
53
+ end
54
+
47
55
  def log_debug(message)
48
- Legion::Logging.debug(message) if defined?(Legion::Logging)
56
+ log.debug(message)
49
57
  end
50
58
 
51
59
  def log_warn(message)
52
- defined?(Legion::Logging) ? Legion::Logging.warn(message) : warn(message)
60
+ log.warn(message)
53
61
  end
54
62
  end
55
63
  end
@@ -5,10 +5,13 @@ require 'json'
5
5
  require 'net/http'
6
6
  require 'resolv'
7
7
  require 'uri'
8
+ require 'legion/logging'
8
9
 
9
10
  module Legion
10
11
  module Settings
11
12
  class DnsBootstrap
13
+ include Legion::Logging::Helper
14
+
12
15
  CACHE_FILENAME = '_dns_bootstrap.json'
13
16
  HOSTNAME_PREFIX = 'legion-bootstrap'
14
17
  URL_PATH = '/legion/bootstrap.json'
@@ -28,7 +31,7 @@ module Legion
28
31
  Resolv.getaddress(@hostname)
29
32
  true
30
33
  rescue Resolv::ResolvError, Resolv::ResolvTimeout => e
31
- log_debug("Legion::Settings::DnsBootstrap#resolve? could not resolve #{@hostname}: #{e.message}")
34
+ log.debug("Legion::Settings::DnsBootstrap#resolve? could not resolve #{@hostname}: #{e.message}")
32
35
  false
33
36
  end
34
37
 
@@ -45,7 +48,7 @@ module Legion
45
48
 
46
49
  ::JSON.parse(response.body, symbolize_names: true)
47
50
  rescue StandardError => e
48
- log_warn("DNS bootstrap fetch failed for #{@url}: #{e.message}")
51
+ log.warn("DNS bootstrap fetch failed for #{@url}: #{e.message}")
49
52
  nil
50
53
  end
51
54
 
@@ -67,11 +70,11 @@ module Legion
67
70
  return nil unless File.exist?(@cache_path)
68
71
 
69
72
  raw = ::JSON.parse(File.read(@cache_path), symbolize_names: true)
70
- log_debug("DNS bootstrap cache hit: #{@cache_path}")
73
+ log.debug("DNS bootstrap cache hit: #{@cache_path}")
71
74
  raw.delete(:_dns_bootstrap_meta)
72
75
  raw
73
76
  rescue ::JSON::ParserError
74
- log_warn("DNS bootstrap cache corrupt, deleting: #{@cache_path}")
77
+ log.warn("DNS bootstrap cache corrupt, deleting: #{@cache_path}")
75
78
  FileUtils.rm_f(@cache_path)
76
79
  nil
77
80
  end
@@ -82,12 +85,9 @@ module Legion
82
85
 
83
86
  private
84
87
 
85
- def log_debug(message)
86
- Legion::Logging.debug(message) if defined?(Legion::Logging)
87
- end
88
-
89
- def log_warn(message)
90
- defined?(Legion::Logging) ? Legion::Logging.warn(message) : warn(message)
88
+ def resolve_logger_settings
89
+ raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
90
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
91
91
  end
92
92
  end
93
93
  end
@@ -8,7 +8,7 @@ module Legion
8
8
  if Legion::Settings[:extensions]&.key?(ext_key)
9
9
  Legion::Settings[:extensions][ext_key]
10
10
  else
11
- { logger: { level: 'info', extended: false, internal: false } }
11
+ {}
12
12
  end
13
13
  end
14
14
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'resolv'
4
4
  require 'socket'
5
+ require 'digest'
6
+ require 'tmpdir'
7
+ require 'legion/logging'
5
8
  require 'legion/settings/os'
6
9
  require_relative 'dns_bootstrap'
7
10
 
@@ -9,6 +12,7 @@ module Legion
9
12
  module Settings
10
13
  class Loader
11
14
  include Legion::Settings::OS
15
+ include Legion::Logging::Helper
12
16
 
13
17
  class Error < RuntimeError; end
14
18
  attr_reader :warnings, :errors, :loaded_files, :settings
@@ -36,6 +40,7 @@ module Legion
36
40
  @settings = default_settings
37
41
  @indifferent_access = false
38
42
  @loaded_files = []
43
+ log.debug('Initialized Legion::Settings::Loader with default settings')
39
44
  end
40
45
 
41
46
  def dns_defaults
@@ -138,16 +143,14 @@ module Legion
138
143
  reload: false,
139
144
  reloading: false,
140
145
  auto_install_missing_lex: true,
141
- default_extension_settings: {
142
- logger: { level: 'info', trace: false, extended: false }
143
- },
146
+ default_extension_settings: {},
144
147
  logging: logging_defaults,
145
148
  absorbers: absorbers_defaults,
146
149
  transport: { connected: false },
147
150
  data: { connected: false },
148
151
  role: { profile: nil, extensions: [] },
149
152
  region: { current: nil, primary: nil, failover: nil, peers: [],
150
- default_affinity: 'prefer_local', data_residency: {} },
153
+ default_affinity: 'any', data_residency: {} },
151
154
  process: { role: 'full' },
152
155
  dns: dns_defaults
153
156
  }
@@ -171,7 +174,7 @@ module Legion
171
174
 
172
175
  def []=(key, value)
173
176
  @settings[key] = value
174
- @indifferent_access = false
177
+ mark_dirty!
175
178
  end
176
179
 
177
180
  def hexdigest
@@ -220,16 +223,14 @@ module Legion
220
223
  mod_name = config.keys.first
221
224
  log_debug("Loading module settings: #{mod_name}")
222
225
  @settings = deep_merge(config, @settings)
223
- @indifferent_access = false
226
+ mark_dirty!
224
227
  end
225
228
 
226
229
  def load_module_default(config)
227
230
  mod_name = config.keys.first
228
231
  log_debug("Loading module defaults: #{mod_name}")
229
- merged = deep_merge(@settings, config)
230
- deep_diff(@settings, merged) unless @loaded_files.empty?
231
- @settings = merged
232
- @indifferent_access = false
232
+ @settings = deep_merge(config, @settings)
233
+ mark_dirty!
233
234
  end
234
235
 
235
236
  def load_file(file)
@@ -238,11 +239,10 @@ module Legion
238
239
  begin
239
240
  contents = read_config_file(file)
240
241
  config = contents.empty? ? {} : Legion::JSON.load(contents)
241
- merged = deep_merge(@settings, config)
242
- deep_diff(@settings, merged) unless @loaded_files.empty?
243
- @settings = merged
244
- # @indifferent_access = false
242
+ @settings = deep_merge(@settings, config)
243
+ mark_dirty!
245
244
  @loaded_files << file
245
+ log.debug("Loaded settings file #{file}")
246
246
  rescue Legion::JSON::ParseError => e
247
247
  log_error("config file must be valid json: #{file}")
248
248
  log_error(" parse error: #{e.message}")
@@ -255,7 +255,7 @@ module Legion
255
255
  def load_directory(directory)
256
256
  path = directory.gsub(/\\(?=\S)/, '/')
257
257
  if File.readable?(path) && File.executable?(path)
258
- files = Dir.glob(File.join(path, '**{,/*/**}/*.json')).uniq
258
+ files = Dir.glob(File.join(path, '**', '*.json'))
259
259
  files.each { |file| load_file(file) }
260
260
  log_info("Settings: loaded directory #{path} (#{files.size} files)")
261
261
  else
@@ -268,7 +268,7 @@ module Legion
268
268
  if @settings[:client][:subscriptions].is_a?(Array)
269
269
  @settings[:client][:subscriptions] << "client:#{@settings[:client][:name]}"
270
270
  @settings[:client][:subscriptions].uniq!
271
- @indifferent_access = false
271
+ mark_dirty!
272
272
  else
273
273
  log_warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
274
274
  end
@@ -290,6 +290,11 @@ module Legion
290
290
 
291
291
  private
292
292
 
293
+ def resolve_logger_settings
294
+ raw_logging = instance_variable_defined?(:@settings) ? @settings&.[](:logging) : nil
295
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
296
+ end
297
+
293
298
  def load_dns_from_cache(bootstrap)
294
299
  config = bootstrap.read_cache
295
300
  start_dns_background_refresh(bootstrap) if config
@@ -311,7 +316,7 @@ module Legion
311
316
  hostname: bootstrap.hostname,
312
317
  url: bootstrap.url
313
318
  }
314
- @indifferent_access = false
319
+ mark_dirty!
315
320
  end
316
321
 
317
322
  def start_dns_background_refresh(bootstrap)
@@ -357,14 +362,14 @@ module Legion
357
362
  @settings[:api] ||= {}
358
363
  @settings[:api][:port] = ENV['LEGION_API_PORT'].to_i
359
364
  log_warn("using api port environment variable, api: #{@settings[:api]}")
360
- @indifferent_access = false
365
+ mark_dirty!
361
366
  end
362
367
 
363
368
  def load_privacy_env
364
369
  return unless ENV['LEGION_ENTERPRISE_PRIVACY'] == 'true'
365
370
 
366
371
  @settings[:enterprise_data_privacy] = true
367
- @indifferent_access = false
372
+ mark_dirty!
368
373
  end
369
374
 
370
375
  def read_config_file(file)
@@ -394,19 +399,6 @@ module Legion
394
399
  merged
395
400
  end
396
401
 
397
- def deep_diff(hash_one, hash_two)
398
- keys = hash_one.keys.concat(hash_two.keys).uniq
399
- keys.each_with_object({}) do |key, diff|
400
- next if hash_one[key] == hash_two[key]
401
-
402
- diff[key] = if hash_one[key].is_a?(Hash) && hash_two[key].is_a?(Hash)
403
- deep_diff(hash_one[key], hash_two[key])
404
- else
405
- [hash_one[key], hash_two[key]]
406
- end
407
- end
408
- end
409
-
410
402
  def create_loaded_tempfile!
411
403
  dir = ENV['LEGION_LOADED_TEMPFILE_DIR'] || Dir.tmpdir
412
404
  file_name = "legion_#{legion_service_name}_loaded_files"
@@ -415,6 +407,15 @@ module Legion
415
407
  path
416
408
  end
417
409
 
410
+ public
411
+
412
+ def mark_dirty!
413
+ @indifferent_access = false
414
+ @hexdigest = nil
415
+ end
416
+
417
+ private
418
+
418
419
  def legion_service_name
419
420
  File.basename($PROGRAM_NAME).split('-').last
420
421
  end
@@ -422,7 +423,7 @@ module Legion
422
423
  def system_hostname
423
424
  Socket.gethostname
424
425
  rescue StandardError => e
425
- Legion::Logging.debug("Legion::Settings::Loader#system_hostname failed: #{e.message}") if defined?(Legion::Logging)
426
+ log_debug("Legion::Settings::Loader#system_hostname failed: #{e.message}")
426
427
  'unknown'
427
428
  end
428
429
 
@@ -431,7 +432,7 @@ module Legion
431
432
  preferred = addresses.find { |a| rfc1918?(a.ip_address) }
432
433
  (preferred || addresses.first)&.ip_address || 'unknown'
433
434
  rescue StandardError => e
434
- Legion::Logging.debug("Legion::Settings::Loader#system_address failed: #{e.message}") if defined?(Legion::Logging)
435
+ log_debug("Legion::Settings::Loader#system_address failed: #{e.message}")
435
436
  'unknown'
436
437
  end
437
438
 
@@ -442,19 +443,19 @@ module Legion
442
443
  end
443
444
 
444
445
  def log_info(message)
445
- defined?(Legion::Logging) ? Legion::Logging.info(message) : $stdout.puts(message)
446
+ log.info(message)
446
447
  end
447
448
 
448
449
  def log_debug(message)
449
- Legion::Logging.debug(message) if defined?(Legion::Logging)
450
+ log.debug(message)
450
451
  end
451
452
 
452
453
  def log_warn(message)
453
- defined?(Legion::Logging) ? Legion::Logging.warn(message) : warn(message)
454
+ log.warn(message)
454
455
  end
455
456
 
456
457
  def log_error(message)
457
- defined?(Legion::Logging) ? Legion::Logging.error(message) : warn(message)
458
+ log.error(message)
458
459
  end
459
460
 
460
461
  def warning(message, data = {})
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ # Thread-local request-scoped settings overlay.
6
+ #
7
+ # Provides block-scoped overrides that sit above global settings in the
8
+ # resolution order: request overlay > project .legionio.env > global settings.
9
+ #
10
+ # Usage:
11
+ # Legion::Settings.with_overlay(llm: { default_model: 'claude-3-haiku' }) do
12
+ # Legion::Settings[:llm][:default_model] # => 'claude-3-haiku'
13
+ # end
14
+ #
15
+ # Overlays are nestable — inner overlay merges on top of the outer one.
16
+ module Overlay
17
+ THREAD_KEY = :legion_settings_overlay
18
+
19
+ class << self
20
+ # Execute a block with the given overrides active in the current thread.
21
+ # The overrides hash uses the same top-level key structure as Settings.
22
+ #
23
+ # @param overrides [Hash] settings to override for the duration of the block
24
+ # @yield block executed with the overlay active
25
+ # @return the return value of the block
26
+ def with_overlay(overrides)
27
+ previous = Thread.current[THREAD_KEY]
28
+ Thread.current[THREAD_KEY] = deep_merge(previous || {}, overrides)
29
+ yield
30
+ ensure
31
+ Thread.current[THREAD_KEY] = previous
32
+ end
33
+
34
+ # Return the current thread-local overlay hash, or nil if none is active.
35
+ #
36
+ # @return [Hash, nil]
37
+ def current_overlay
38
+ Thread.current[THREAD_KEY]
39
+ end
40
+
41
+ # Clear the thread-local overlay for the current thread.
42
+ def clear_overlay!
43
+ Thread.current[THREAD_KEY] = nil
44
+ end
45
+
46
+ # Resolve a top-level key against the active overlay, returning the
47
+ # overlay value (which may need to be merged with base) or nil when no
48
+ # overlay is set.
49
+ #
50
+ # @param key [Symbol, String]
51
+ # @return [Object, nil]
52
+ def overlay_for(key)
53
+ overlay = Thread.current[THREAD_KEY]
54
+ return nil unless overlay
55
+
56
+ sym_key = key.to_sym
57
+ str_key = key.to_s
58
+ overlay[sym_key] || overlay[str_key]
59
+ end
60
+
61
+ private
62
+
63
+ def deep_merge(base, overrides)
64
+ result = base.dup
65
+ overrides.each do |key, value|
66
+ existing = result[key]
67
+ result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
68
+ deep_merge(existing, value)
69
+ else
70
+ value
71
+ end
72
+ end
73
+ result
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+
5
+ module Legion
6
+ module Settings
7
+ # Per-project `.legionio.env` config file loader.
8
+ #
9
+ # Walks up from Dir.pwd searching for a `.legionio.env` file. When found,
10
+ # parses `KEY=VALUE` lines with dot-notation keys and merges them into the
11
+ # loader at a priority between global settings and the request overlay.
12
+ #
13
+ # File format:
14
+ # # comment lines are ignored
15
+ # llm.default_model=claude-sonnet-4-5-20241022
16
+ # cache.driver=redis
17
+ #
18
+ # Keys use dot notation to address nested settings paths.
19
+ # Values are always strings; callers should coerce as needed.
20
+ #
21
+ # Resolution order (lowest → highest priority):
22
+ # global settings < .legionio.env < request overlay (#9)
23
+ module ProjectEnv
24
+ extend Legion::Logging::Helper
25
+
26
+ ENV_FILENAME = '.legionio.env'
27
+
28
+ class << self
29
+ # Walk up from +start_dir+ (defaults to Dir.pwd) looking for
30
+ # `.legionio.env`. Returns the first file found, or nil.
31
+ #
32
+ # @param start_dir [String, nil] directory to start the search from
33
+ # @return [String, nil] absolute path to the file, or nil
34
+ def find_project_env_file(start_dir: nil)
35
+ dir = File.expand_path(start_dir || Dir.pwd)
36
+ loop do
37
+ candidate = File.join(dir, ENV_FILENAME)
38
+ return candidate if File.file?(candidate) && File.readable?(candidate)
39
+
40
+ parent = File.dirname(dir)
41
+ break if parent == dir # filesystem root
42
+
43
+ dir = parent
44
+ end
45
+ nil
46
+ end
47
+
48
+ # Parse a `.legionio.env` file and return a nested hash of overrides.
49
+ #
50
+ # @param path [String] absolute path to the file
51
+ # @return [Hash] nested hash with symbol keys
52
+ def parse_env_file(path)
53
+ result = {}
54
+ File.readlines(path, chomp: true).each_with_index do |line, idx|
55
+ next if line.strip.empty?
56
+ next if line.strip.start_with?('#')
57
+
58
+ parts = line.split('=', 2)
59
+ unless parts.length == 2
60
+ log_warn("#{path}:#{idx + 1}: skipping malformed line (no '=' found)")
61
+ next
62
+ end
63
+
64
+ raw_key, value = parts
65
+ key_parts = raw_key.strip.split('.')
66
+ if key_parts.empty? || key_parts.any?(&:empty?)
67
+ log_warn("#{path}:#{idx + 1}: skipping invalid key '#{raw_key.strip}'")
68
+ next
69
+ end
70
+
71
+ set_nested(result, key_parts.map(&:to_sym), value.strip)
72
+ end
73
+ result
74
+ end
75
+
76
+ # Find and load the project env file into the given settings hash,
77
+ # merging overrides (env file values win over existing values).
78
+ #
79
+ # @param settings [Hash] the settings hash to merge into (mutated in place)
80
+ # @param start_dir [String, nil] directory to start searching from
81
+ # @return [String, nil] path to the loaded file, or nil if none found
82
+ def load_into(settings, start_dir: nil)
83
+ path = find_project_env_file(start_dir: start_dir)
84
+ return nil unless path
85
+
86
+ overrides = parse_env_file(path)
87
+ deep_merge_into!(settings, overrides)
88
+ log_debug("ProjectEnv: loaded #{path}")
89
+ path
90
+ end
91
+
92
+ private
93
+
94
+ def resolve_logger_settings
95
+ raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
96
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
97
+ end
98
+
99
+ def set_nested(hash, keys, value)
100
+ *parents, leaf = keys
101
+ target = parents.reduce(hash) do |h, k|
102
+ h[k] ||= {}
103
+ h[k]
104
+ end
105
+ target[leaf] = value
106
+ end
107
+
108
+ def deep_merge_into!(base, overrides)
109
+ overrides.each do |key, value|
110
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
111
+ deep_merge_into!(base[key], value)
112
+ else
113
+ base[key] = value
114
+ end
115
+ end
116
+ base
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
+ end
127
+ end
128
+ end
129
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module Settings
5
7
  module Resolver
8
+ extend Legion::Logging::Helper
9
+
6
10
  VAULT_PATTERN = %r{\Avault://(.+?)#(.+)\z}
7
11
  ENV_PATTERN = %r{\Aenv://(.+)\z}
8
12
  LEASE_PATTERN = %r{\Alease://(.+?)#(.+)\z}
@@ -81,60 +85,79 @@ module Legion
81
85
  end
82
86
 
83
87
  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
88
+ case hash
89
+ when String then hash.match?(VAULT_PATTERN) ? 1 : 0
90
+ when Array then hash.sum { |value| count_vault_refs(value) }
91
+ when Hash then hash.sum { |_key, value| count_vault_refs(value) }
92
+ else 0
93
93
  end
94
94
  end
95
95
 
96
96
  def vault_connected?
97
97
  return false unless defined?(Legion::Crypt)
98
98
  return false unless defined?(Legion::Settings)
99
+ return Legion::Crypt.vault_connected? if Legion::Crypt.respond_to?(:vault_connected?)
99
100
 
100
- Legion::Settings[:crypt][:vault][:connected] == true
101
+ Legion::Settings[:crypt][:vault][:connected] == true ||
102
+ (Legion::Crypt.respond_to?(:connected_clusters) && Legion::Crypt.connected_clusters.any?)
101
103
  rescue StandardError => e
102
104
  log_debug("Legion::Settings::Resolver#vault_connected? failed: #{e.message}")
103
105
  false
104
106
  end
105
107
 
106
- def walk(hash, path:, &block)
107
- hash.each do |key, value|
108
- current_path = path.empty? ? key.to_s : "#{path}.#{key}"
109
-
110
- case value
111
- when Hash
112
- walk(value, path: current_path, &block)
113
- when String
114
- next unless value.match?(URI_PATTERN)
115
-
116
- resolved = resolve_single(value)
117
- if resolved.nil?
118
- log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
119
- block&.call(:unresolved)
120
- else
121
- hash[key] = resolved
122
- register_lease_ref(value, current_path) if value.match?(LEASE_PATTERN)
123
- block&.call(:resolved)
124
- end
125
- when Array
126
- next unless resolvable_chain?(value)
127
-
128
- resolved = resolve_chain(value)
129
- if resolved.nil?
130
- log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
131
- block&.call(:unresolved)
132
- else
133
- hash[key] = resolved
134
- register_lease_refs_from_chain(value, current_path)
135
- block&.call(:resolved)
136
- end
108
+ def walk(hash, path:, &)
109
+ case hash
110
+ when Hash
111
+ hash.each do |key, value|
112
+ current_path = path.empty? ? key.to_s : "#{path}.#{key}"
113
+ walk_value(hash, key, value, current_path, &)
114
+ end
115
+ when Array
116
+ hash.each_with_index do |value, index|
117
+ current_path = "#{path}[#{index}]"
118
+ walk_value(hash, index, value, current_path, &)
119
+ end
120
+ end
121
+ end
122
+
123
+ def walk_value(container, key, value, current_path, &)
124
+ case value
125
+ when Hash
126
+ walk(value, path: current_path, &)
127
+ when String
128
+ handle_string_value(container, key, value, current_path) { |status| yield(status) if block_given? }
129
+ when Array
130
+ handle_array_value(container, key, value, current_path) { |status| yield(status) if block_given? }
131
+ end
132
+ end
133
+
134
+ def handle_string_value(container, key, value, current_path)
135
+ return unless value.match?(URI_PATTERN)
136
+
137
+ resolved = resolve_single(value)
138
+ if resolved.nil?
139
+ log_warn("Settings resolver: could not resolve #{current_path} (#{value})")
140
+ yield(:unresolved) if block_given?
141
+ else
142
+ container[key] = resolved
143
+ register_lease_ref(value, current_path) if value.match?(LEASE_PATTERN)
144
+ yield(:resolved) if block_given?
145
+ end
146
+ end
147
+
148
+ def handle_array_value(container, key, value, current_path, &)
149
+ if resolvable_chain?(value) && value.all? { |entry| !entry.is_a?(Hash) && !entry.is_a?(Array) }
150
+ resolved = resolve_chain(value)
151
+ if resolved.nil?
152
+ log_warn("Settings resolver: fallback chain exhausted for #{current_path}")
153
+ yield(:unresolved) if block_given?
154
+ else
155
+ container[key] = resolved
156
+ register_lease_refs_from_chain(value, current_path)
157
+ yield(:resolved) if block_given?
137
158
  end
159
+ else
160
+ walk(value, path: current_path, &)
138
161
  end
139
162
  end
140
163
 
@@ -207,40 +230,29 @@ module Legion
207
230
  end
208
231
 
209
232
  def count_lease_refs(hash)
210
- return 0 unless hash.is_a?(Hash)
211
-
212
- hash.sum do |_key, value|
213
- case value
214
- when String then value.match?(LEASE_PATTERN) ? 1 : 0
215
- when Array then value.count { |v| v.is_a?(String) && v.match?(LEASE_PATTERN) }
216
- when Hash then count_lease_refs(value)
217
- else 0
218
- end
233
+ case hash
234
+ when String then hash.match?(LEASE_PATTERN) ? 1 : 0
235
+ when Array then hash.sum { |value| count_lease_refs(value) }
236
+ when Hash then hash.sum { |_key, value| count_lease_refs(value) }
237
+ else 0
219
238
  end
220
239
  end
221
240
 
241
+ def resolve_logger_settings
242
+ raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
243
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
244
+ end
245
+
222
246
  def log_info(message)
223
- if defined?(Legion::Logging)
224
- Legion::Logging.info(message)
225
- else
226
- $stdout.puts(message)
227
- end
247
+ log.info(message)
228
248
  end
229
249
 
230
250
  def log_warn(message)
231
- if defined?(Legion::Logging)
232
- Legion::Logging.warn(message)
233
- else
234
- warn(message)
235
- end
251
+ log.warn(message)
236
252
  end
237
253
 
238
254
  def log_debug(message)
239
- if defined?(Legion::Logging)
240
- Legion::Logging.debug(message)
241
- else
242
- $stdout.puts(message)
243
- end
255
+ log.debug(message)
244
256
  end
245
257
  end
246
258
  end
@@ -113,7 +113,14 @@ module Legion
113
113
  if constraint.is_a?(Hash) && constraint.key?(:type)
114
114
  validate_leaf(constraint, value, mod_name, current_path, errors)
115
115
  elsif constraint.is_a?(Hash)
116
- validate_node(constraint, value, mod_name, current_path, errors) if value.is_a?(Hash)
116
+ next if value.nil?
117
+
118
+ if value.is_a?(Hash)
119
+ validate_node(constraint, value, mod_name, current_path, errors)
120
+ else
121
+ errors << { module: mod_name, path: current_path,
122
+ message: "expected Hash, got #{value.class} (#{value.inspect})" }
123
+ end
117
124
  end
118
125
  end
119
126
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.3.24'
5
+ VERSION = '1.3.26'
6
6
  end
7
7
  end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/json'
4
+ require 'legion/logging'
4
5
  require 'legion/settings/version'
5
6
  require 'legion/json/parse_error'
6
7
  require 'legion/settings/loader'
7
8
  require 'legion/settings/schema'
8
9
  require 'legion/settings/validation_error'
9
10
  require 'legion/settings/helper'
11
+ require 'legion/settings/overlay'
12
+ require 'legion/settings/project_env'
10
13
 
11
14
  module Legion
12
15
  module Settings
@@ -15,6 +18,8 @@ module Legion
15
18
  class << self
16
19
  attr_accessor :loader
17
20
 
21
+ include Legion::Logging::Helper
22
+
18
23
  def load(options = {})
19
24
  has_config = options[:config_file] || options[:config_dir] || options[:config_dirs]&.any?
20
25
 
@@ -35,6 +40,7 @@ module Legion
35
40
  end
36
41
 
37
42
  @loaded = true if has_config
43
+ load_project_env
38
44
  logger.info("Settings loaded from #{@loader.loaded_files.size} files")
39
45
  @loader
40
46
  end
@@ -48,19 +54,35 @@ module Legion
48
54
  end
49
55
 
50
56
  def [](key)
51
- logger.info('Legion::Settings was not loading, auto loading now!') if @loader.nil?
52
- ensure_loader
53
- @loader[key]
57
+ logger.info('Legion::Settings was not loaded, auto-loading now') if @loader.nil?
58
+ load if @loader.nil?
59
+ overlay_val = Overlay.overlay_for(key)
60
+ base_val = @loader[key]
61
+ if overlay_val.is_a?(Hash) && base_val.is_a?(Hash)
62
+ deep_merge_for_overlay(base_val, overlay_val)
63
+ elsif !overlay_val.nil?
64
+ overlay_val
65
+ else
66
+ base_val
67
+ end
54
68
  rescue NoMethodError, TypeError => e
55
- Legion::Logging.debug("Legion::Settings#[] key=#{key} failed: #{e.message}") if defined?(Legion::Logging)
69
+ logger.debug("Legion::Settings#[] key=#{key} failed: #{e.message}")
56
70
  nil
57
71
  end
58
72
 
59
73
  def dig(*keys)
60
- ensure_loader
61
- @loader.dig(*keys)
74
+ return nil if keys.empty?
75
+
76
+ logger.info('Legion::Settings was not loaded, auto-loading now') if @loader.nil?
77
+ load if @loader.nil?
78
+
79
+ root = self[keys.first]
80
+ return root if keys.length == 1
81
+ return nil unless root.respond_to?(:dig)
82
+
83
+ root.dig(*keys[1..])
62
84
  rescue NoMethodError, TypeError => e
63
- Legion::Logging.debug("Legion::Settings#dig keys=#{keys.inspect} failed: #{e.message}") if defined?(Legion::Logging)
85
+ logger.debug("Legion::Settings#dig keys=#{keys.inspect} failed: #{e.message}")
64
86
  nil
65
87
  end
66
88
 
@@ -86,12 +108,36 @@ module Legion
86
108
  cross_validations << block
87
109
  end
88
110
 
111
+ # Execute a block with thread-local settings overrides active.
112
+ # Overlays are nestable — inner overlays merge on top of outer ones.
113
+ # Resolution order inside the block: overlay > project env > global settings.
114
+ #
115
+ # @param overrides [Hash] settings to override for the duration of the block
116
+ # @yield the block executed with the overlay active
117
+ # @return the return value of the block
118
+ def with_overlay(overrides, &)
119
+ Overlay.with_overlay(overrides, &)
120
+ end
121
+
122
+ # Load (or reload) the nearest `.legionio.env` file into the settings loader.
123
+ # Searches from Dir.pwd upward. Env-file values take priority over global
124
+ # settings but are overridden by a request overlay (with_overlay).
125
+ #
126
+ # @param start_dir [String, nil] directory to start searching from (defaults to Dir.pwd)
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
132
+ path
133
+ end
134
+
89
135
  def dev_mode?
90
136
  return true if ENV['LEGION_DEV'] == 'true'
91
137
 
92
138
  Legion::Settings[:dev] ? true : false
93
139
  rescue StandardError => e
94
- Legion::Logging.debug("Legion::Settings#dev_mode? failed: #{e.message}") if defined?(Legion::Logging)
140
+ logger.debug("Legion::Settings#dev_mode? failed: #{e.message}")
95
141
  false
96
142
  end
97
143
 
@@ -100,7 +146,7 @@ module Legion
100
146
 
101
147
  Legion::Settings[:enterprise_data_privacy] ? true : false
102
148
  rescue StandardError => e
103
- Legion::Logging.debug("Legion::Settings#enterprise_privacy? failed: #{e.message}") if defined?(Legion::Logging)
149
+ logger.debug("Legion::Settings#enterprise_privacy? failed: #{e.message}")
104
150
  false
105
151
  end
106
152
 
@@ -143,28 +189,39 @@ module Legion
143
189
  @loaded = nil
144
190
  @schema = nil
145
191
  @cross_validations = nil
192
+ Overlay.clear_overlay!
146
193
  end
147
194
 
148
195
  def logger
149
- @logger = if ::Legion.const_defined?('Logging')
150
- ::Legion::Logging
151
- else
152
- require 'logger'
153
- l = ::Logger.new($stdout)
154
- l.formatter = proc do |severity, datetime, _progname, msg|
155
- "[#{datetime.strftime('%Y-%m-%d %H:%M:%S %z')}] #{severity} #{msg}\n"
156
- end
157
- l
158
- end
196
+ log
159
197
  end
160
198
 
161
199
  private
162
200
 
201
+ def resolve_logger_settings
202
+ raw_logging = @loader&.settings&.dig(:logging)
203
+ raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
204
+ end
205
+
206
+ def deep_merge_for_overlay(base, overlay)
207
+ result = base.dup
208
+ overlay.each do |key, value|
209
+ existing = result[key]
210
+ result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
211
+ deep_merge_for_overlay(existing, value)
212
+ else
213
+ value
214
+ end
215
+ end
216
+ result
217
+ end
218
+
163
219
  def ensure_loader
164
220
  return @loader if @loader
165
221
 
166
222
  @loader = Legion::Settings::Loader.new
167
223
  @loader.load_env
224
+ logger.debug('Initialized Legion::Settings loader without config files')
168
225
  @loader
169
226
  end
170
227
 
@@ -177,11 +234,7 @@ module Legion
177
234
  label = count == 1 ? 'error' : 'errors'
178
235
  message = "Legion::Settings dev mode: #{count} configuration #{label} detected (not raising):\n"
179
236
  message += errs.map { |e| " [#{e[:module]}] #{e[:path]}: #{e[:message]}" }.join("\n")
180
- if ::Legion.const_defined?('Logging')
181
- ::Legion::Logging.warn(message)
182
- else
183
- warn(message)
184
- end
237
+ logger.warn(message)
185
238
  end
186
239
 
187
240
  def validate_module_on_merge(mod_name)
@@ -193,6 +246,7 @@ module Legion
193
246
  end
194
247
 
195
248
  def revalidate_all_modules
249
+ @loader.errors.clear
196
250
  schema.registered_modules.each do |mod_name|
197
251
  values = @loader[mod_name]
198
252
  next unless values.is_a?(Hash)
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.24
4
+ version: 1.3.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: 1.2.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: legion-logging
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.5.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.5.0
26
40
  description: A gem written to handle LegionIO Settings in a consistent way across
27
41
  extensions
28
42
  email:
@@ -54,6 +68,8 @@ files:
54
68
  - lib/legion/settings/helper.rb
55
69
  - lib/legion/settings/loader.rb
56
70
  - lib/legion/settings/os.rb
71
+ - lib/legion/settings/overlay.rb
72
+ - lib/legion/settings/project_env.rb
57
73
  - lib/legion/settings/resolver.rb
58
74
  - lib/legion/settings/schema.rb
59
75
  - lib/legion/settings/validation_error.rb