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 +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile +1 -0
- data/README.md +22 -14
- data/legion-settings.gemspec +1 -0
- data/lib/legion/settings/agent_loader.rb +10 -2
- data/lib/legion/settings/dns_bootstrap.rb +10 -10
- data/lib/legion/settings/helper.rb +1 -1
- data/lib/legion/settings/loader.rb +39 -38
- data/lib/legion/settings/overlay.rb +78 -0
- data/lib/legion/settings/project_env.rb +129 -0
- data/lib/legion/settings/resolver.rb +77 -65
- data/lib/legion/settings/schema.rb +8 -1
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +78 -24
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4d1e8b1f3f0f96ee8db9d11604d914fc253e430827e37f7e30cc244151dc75b
|
|
4
|
+
data.tar.gz: 1ed13e9f16b97fc33acc72e484671794210ac4750aef5502fc86e685ec2f9324
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.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
|
|
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
|
|
28
|
+
Legion::Settings.dig(:transport, :connection, :host)
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
2. `~/.legionio/settings/`
|
|
34
|
-
3. `~/legionio/`
|
|
35
|
-
4. `./settings/`
|
|
33
|
+
### Config Loading
|
|
36
34
|
|
|
37
|
-
|
|
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
|
|
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
|
|
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":
|
|
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":
|
|
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.
|
|
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
|
|
data/legion-settings.gemspec
CHANGED
|
@@ -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
|
-
|
|
56
|
+
log.debug(message)
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def log_warn(message)
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
Legion::
|
|
87
|
-
|
|
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
|
|
@@ -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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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, '**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
+
log.info(message)
|
|
446
447
|
end
|
|
447
448
|
|
|
448
449
|
def log_debug(message)
|
|
449
|
-
|
|
450
|
+
log.debug(message)
|
|
450
451
|
end
|
|
451
452
|
|
|
452
453
|
def log_warn(message)
|
|
453
|
-
|
|
454
|
+
log.warn(message)
|
|
454
455
|
end
|
|
455
456
|
|
|
456
457
|
def log_error(message)
|
|
457
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
hash.sum
|
|
87
|
-
|
|
88
|
-
|
|
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:, &
|
|
107
|
-
hash
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
hash.sum
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/legion/settings.rb
CHANGED
|
@@ -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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
69
|
+
logger.debug("Legion::Settings#[] key=#{key} failed: #{e.message}")
|
|
56
70
|
nil
|
|
57
71
|
end
|
|
58
72
|
|
|
59
73
|
def dig(*keys)
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|