legion-settings 1.3.4 → 1.3.6
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 +14 -0
- data/CLAUDE.md +14 -1
- data/README.md +2 -0
- data/lib/legion/settings/dns_bootstrap.rb +80 -0
- data/lib/legion/settings/loader.rb +108 -9
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 226a1ae2303f64b96fc48bd42101545a9d06b73aefab19278f27007c406c39d2
|
|
4
|
+
data.tar.gz: 4cbf2414166794436869ed44646f86c2860e7bdad4a07feebbd2536fe0e15276
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b719320bd415ffbf14d68c5a43122dddf8182a0cfddca05915ba8f36490ede658abcb74fa499f320711a20fb5ec67d4f108c17fa4a1b35ee6a51dac801d0402
|
|
7
|
+
data.tar.gz: 88220500e2cc7a8542ce32b26c94b8ccf143d6b90d7ef0054a32f8d4507d175534dd8ac4ddae724ac8cfaa1fb4e88ac099200ca7d028fe4e258fe945398d939a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Legion::Settings Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.6] - 2026-03-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Guard all `Legion::Logging` calls in loader with `defined?` check to prevent `NameError` when legion-logging is not loaded (fixes CI for downstream gems like legion-transport)
|
|
7
|
+
|
|
8
|
+
## [1.3.5] - 2026-03-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- DNS-based bootstrap discovery: auto-detect corporate config from `legion-bootstrap.<search-domain>`
|
|
12
|
+
- `Settings[:dns]` populated with FQDN, default domain, search domains, and nameservers
|
|
13
|
+
- `DnsBootstrap` class with DNS resolution, HTTPS fetch, local caching, and background refresh
|
|
14
|
+
- First boot blocks on fetch; subsequent boots use cache with async refresh
|
|
15
|
+
- Opt-out via `LEGION_DNS_BOOTSTRAP=false` environment variable
|
|
16
|
+
|
|
3
17
|
## [1.3.4] - 2026-03-18
|
|
4
18
|
|
|
5
19
|
### Fixed
|
data/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
Hash-like configuration store for the LegionIO framework. Loads settings from JSON files, directories, and environment variables. Provides a unified `Legion::Settings[:key]` accessor used by all other Legion gems. Includes schema-based validation with type inference, enum constraints, and cross-module checks.
|
|
9
9
|
|
|
10
10
|
**GitHub**: https://github.com/LegionIO/legion-settings
|
|
11
|
+
**Version**: 1.3.5
|
|
11
12
|
**License**: Apache-2.0
|
|
12
13
|
|
|
13
14
|
## Architecture
|
|
@@ -26,11 +27,19 @@ Legion::Settings (singleton module)
|
|
|
26
27
|
│
|
|
27
28
|
├── Loader # Core: loads env vars, files, directories, merges settings
|
|
28
29
|
│ ├── .load_env # Load environment variables (LEGION_API_PORT)
|
|
30
|
+
│ ├── .load_dns_bootstrap # DNS-based corporate config discovery (baseline defaults)
|
|
29
31
|
│ ├── .load_file # Load single JSON file
|
|
30
32
|
│ ├── .load_directory # Load all JSON files from directory
|
|
31
33
|
│ ├── .load_module_settings # Merge with module priority
|
|
32
34
|
│ └── .load_module_default # Merge with default priority
|
|
33
35
|
│
|
|
36
|
+
├── DnsBootstrap # DNS-based corporate config auto-discovery
|
|
37
|
+
│ ├── .resolve? # Check if legion-bootstrap.<domain> resolves
|
|
38
|
+
│ ├── .fetch # HTTPS GET /legion/bootstrap.json
|
|
39
|
+
│ ├── .write_cache # Atomic write to ~/.legionio/settings/_dns_bootstrap.json
|
|
40
|
+
│ ├── .read_cache # Read + strip metadata, delete if corrupted
|
|
41
|
+
│ └── .cache_exists? # Check for cached config
|
|
42
|
+
│
|
|
34
43
|
├── Schema # Type inference, validation, unknown key detection
|
|
35
44
|
│ ├── .register # Infer types from defaults
|
|
36
45
|
│ ├── .define_override # Add enum/required/type constraints
|
|
@@ -45,7 +54,9 @@ Legion::Settings (singleton module)
|
|
|
45
54
|
### Key Design Patterns
|
|
46
55
|
|
|
47
56
|
- **Auto-Load on Access**: `Legion::Settings[:key]` auto-loads if not initialized
|
|
57
|
+
- **DNS Bootstrap Discovery**: On load, resolves `legion-bootstrap.<search-domain>` to fetch corporate baseline config. First boot blocks; subsequent boots use cache + async refresh. Disabled via `LEGION_DNS_BOOTSTRAP=false`
|
|
48
58
|
- **Directory-Based Config**: Loads all `.json` files from config directories (default paths: `/etc/legionio`, `~/legionio`, `./settings`)
|
|
59
|
+
- **Load Priority** (lowest to highest): hardcoded defaults < DNS bootstrap < local JSON files < CLI flags < secret resolution
|
|
49
60
|
- **Module Merging**: Each Legion module registers its defaults via `merge_settings` during startup
|
|
50
61
|
- **Schema Inference**: Types are inferred from default values — no manual schema definitions needed
|
|
51
62
|
- **Two-Pass Validation**: Per-module on merge (catches type mismatches immediately) + cross-module on `validate!` (catches dependency conflicts)
|
|
@@ -69,13 +80,15 @@ Legion::Settings (singleton module)
|
|
|
69
80
|
| `lib/legion/settings/validation_error.rb` | Error collection and formatted reporting |
|
|
70
81
|
| `lib/legion/settings/os.rb` | OS detection helpers |
|
|
71
82
|
| `lib/legion/settings/resolver.rb` | Secret resolution: `vault://` and `env://` URI references, fallback chains |
|
|
83
|
+
| `lib/legion/settings/dns_bootstrap.rb` | DNS-based corporate config discovery, caching, background refresh |
|
|
72
84
|
| `lib/legion/settings/version.rb` | VERSION constant |
|
|
73
85
|
| `spec/legion/settings_spec.rb` | Core settings module tests |
|
|
74
86
|
| `spec/legion/settings_module_spec.rb` | Module-level accessor and merge tests |
|
|
75
87
|
| `spec/legion/loader_spec.rb` | Loader: env/file/directory loading tests |
|
|
76
88
|
| `spec/legion/settings/schema_spec.rb` | Schema validation tests |
|
|
77
89
|
| `spec/legion/settings/validation_error_spec.rb` | Error formatting tests |
|
|
78
|
-
| `spec/legion/settings/integration_spec.rb` | End-to-end validation tests |
|
|
90
|
+
| `spec/legion/settings/integration_spec.rb` | End-to-end validation + DNS bootstrap override tests |
|
|
91
|
+
| `spec/legion/settings/dns_bootstrap_spec.rb` | DnsBootstrap class tests (resolve, fetch, cache) |
|
|
79
92
|
| `spec/legion/settings/role_defaults_spec.rb` | Role profile default settings tests |
|
|
80
93
|
| `spec/legion/settings/resolver_spec.rb` | Secret resolver tests (env://, vault://, lease://, fallback chains) |
|
|
81
94
|
|
data/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
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
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'resolv'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Settings
|
|
11
|
+
class DnsBootstrap
|
|
12
|
+
CACHE_FILENAME = '_dns_bootstrap.json'
|
|
13
|
+
HOSTNAME_PREFIX = 'legion-bootstrap'
|
|
14
|
+
URL_PATH = '/legion/bootstrap.json'
|
|
15
|
+
HTTP_TIMEOUT = 10
|
|
16
|
+
|
|
17
|
+
attr_reader :default_domain, :hostname, :url, :cache_path
|
|
18
|
+
|
|
19
|
+
def initialize(default_domain:, cache_dir: nil)
|
|
20
|
+
@default_domain = default_domain
|
|
21
|
+
@hostname = "#{HOSTNAME_PREFIX}.#{default_domain}"
|
|
22
|
+
@url = "https://#{@hostname}#{URL_PATH}"
|
|
23
|
+
dir = cache_dir || File.expand_path('~/.legionio/settings')
|
|
24
|
+
@cache_path = File.join(dir, CACHE_FILENAME)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve?
|
|
28
|
+
Resolv.getaddress(@hostname)
|
|
29
|
+
true
|
|
30
|
+
rescue Resolv::ResolvError, Resolv::ResolvTimeout
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch
|
|
35
|
+
return nil unless resolve?
|
|
36
|
+
|
|
37
|
+
uri = URI.parse(@url)
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = true
|
|
40
|
+
http.open_timeout = HTTP_TIMEOUT
|
|
41
|
+
http.read_timeout = HTTP_TIMEOUT
|
|
42
|
+
response = http.request(Net::HTTP::Get.new(uri))
|
|
43
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
44
|
+
|
|
45
|
+
::JSON.parse(response.body, symbolize_names: true)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_cache(config)
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(@cache_path))
|
|
52
|
+
payload = config.merge(
|
|
53
|
+
_dns_bootstrap_meta: {
|
|
54
|
+
fetched_at: Time.now.utc.iso8601,
|
|
55
|
+
hostname: @hostname,
|
|
56
|
+
url: @url
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
tmp = "#{@cache_path}.tmp"
|
|
60
|
+
File.write(tmp, ::JSON.pretty_generate(payload))
|
|
61
|
+
File.rename(tmp, @cache_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def read_cache
|
|
65
|
+
return nil unless File.exist?(@cache_path)
|
|
66
|
+
|
|
67
|
+
raw = ::JSON.parse(File.read(@cache_path), symbolize_names: true)
|
|
68
|
+
raw.delete(:_dns_bootstrap_meta)
|
|
69
|
+
raw
|
|
70
|
+
rescue ::JSON::ParserError
|
|
71
|
+
FileUtils.rm_f(@cache_path)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cache_exists?
|
|
76
|
+
File.exist?(@cache_path)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'resolv'
|
|
3
4
|
require 'socket'
|
|
4
5
|
require 'legion/settings/os'
|
|
6
|
+
require_relative 'dns_bootstrap'
|
|
5
7
|
|
|
6
8
|
module Legion
|
|
7
9
|
module Settings
|
|
@@ -19,6 +21,17 @@ module Legion
|
|
|
19
21
|
@loaded_files = []
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
def dns_defaults
|
|
25
|
+
resolv_config = read_resolv_config
|
|
26
|
+
{
|
|
27
|
+
fqdn: detect_fqdn,
|
|
28
|
+
default_domain: resolv_config[:search_domains]&.first,
|
|
29
|
+
search_domains: resolv_config[:search_domains] || [],
|
|
30
|
+
nameservers: resolv_config[:nameservers] || [],
|
|
31
|
+
bootstrap: { enabled: true }
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
22
35
|
def client_defaults
|
|
23
36
|
{
|
|
24
37
|
hostname: system_hostname,
|
|
@@ -71,7 +84,8 @@ module Legion
|
|
|
71
84
|
},
|
|
72
85
|
transport: { connected: false },
|
|
73
86
|
data: { connected: false },
|
|
74
|
-
role: { profile: nil, extensions: [] }
|
|
87
|
+
role: { profile: nil, extensions: [] },
|
|
88
|
+
dns: dns_defaults
|
|
75
89
|
}
|
|
76
90
|
end
|
|
77
91
|
|
|
@@ -116,6 +130,27 @@ module Legion
|
|
|
116
130
|
load_api_env
|
|
117
131
|
end
|
|
118
132
|
|
|
133
|
+
def load_dns_bootstrap(cache_dir: nil)
|
|
134
|
+
return if ENV['LEGION_DNS_BOOTSTRAP'] == 'false'
|
|
135
|
+
|
|
136
|
+
domain = @settings.dig(:dns, :default_domain)
|
|
137
|
+
return unless domain
|
|
138
|
+
return unless @settings.dig(:dns, :bootstrap, :enabled)
|
|
139
|
+
|
|
140
|
+
dir = cache_dir || File.expand_path('~/.legionio/settings')
|
|
141
|
+
bootstrap = DnsBootstrap.new(default_domain: domain, cache_dir: dir)
|
|
142
|
+
|
|
143
|
+
config = if bootstrap.cache_exists?
|
|
144
|
+
load_dns_from_cache(bootstrap)
|
|
145
|
+
else
|
|
146
|
+
load_dns_first_boot(bootstrap)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return unless config
|
|
150
|
+
|
|
151
|
+
merge_dns_config(config, bootstrap)
|
|
152
|
+
end
|
|
153
|
+
|
|
119
154
|
def load_module_settings(config)
|
|
120
155
|
@settings = deep_merge(config, @settings)
|
|
121
156
|
end
|
|
@@ -127,7 +162,7 @@ module Legion
|
|
|
127
162
|
end
|
|
128
163
|
|
|
129
164
|
def load_file(file)
|
|
130
|
-
|
|
165
|
+
log_debug("Trying to load file #{file}")
|
|
131
166
|
if File.file?(file) && File.readable?(file)
|
|
132
167
|
begin
|
|
133
168
|
contents = read_config_file(file)
|
|
@@ -138,11 +173,11 @@ module Legion
|
|
|
138
173
|
# @indifferent_access = false
|
|
139
174
|
@loaded_files << file
|
|
140
175
|
rescue Legion::JSON::ParseError => e
|
|
141
|
-
|
|
142
|
-
|
|
176
|
+
log_error("config file must be valid json: #{file}")
|
|
177
|
+
log_error(" parse error: #{e.message}")
|
|
143
178
|
end
|
|
144
179
|
else
|
|
145
|
-
|
|
180
|
+
log_warn("Config file does not exist or is not readable file:#{file}")
|
|
146
181
|
end
|
|
147
182
|
end
|
|
148
183
|
|
|
@@ -164,7 +199,7 @@ module Legion
|
|
|
164
199
|
@settings[:client][:subscriptions].uniq!
|
|
165
200
|
@indifferent_access = false
|
|
166
201
|
else
|
|
167
|
-
|
|
202
|
+
log_warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
|
|
168
203
|
end
|
|
169
204
|
end
|
|
170
205
|
|
|
@@ -184,6 +219,39 @@ module Legion
|
|
|
184
219
|
|
|
185
220
|
private
|
|
186
221
|
|
|
222
|
+
def load_dns_from_cache(bootstrap)
|
|
223
|
+
config = bootstrap.read_cache
|
|
224
|
+
start_dns_background_refresh(bootstrap) if config
|
|
225
|
+
config
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def load_dns_first_boot(bootstrap)
|
|
229
|
+
log_debug("DNS bootstrap: first boot, fetching from #{bootstrap.url}")
|
|
230
|
+
config = bootstrap.fetch
|
|
231
|
+
bootstrap.write_cache(config) if config
|
|
232
|
+
config
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def merge_dns_config(config, bootstrap)
|
|
236
|
+
@settings = deep_merge(config, @settings)
|
|
237
|
+
@settings[:dns] ||= {}
|
|
238
|
+
@settings[:dns][:corp_bootstrap] = {
|
|
239
|
+
discovered: true,
|
|
240
|
+
hostname: bootstrap.hostname,
|
|
241
|
+
url: bootstrap.url
|
|
242
|
+
}
|
|
243
|
+
@indifferent_access = false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def start_dns_background_refresh(bootstrap)
|
|
247
|
+
Thread.new do
|
|
248
|
+
fresh = bootstrap.fetch
|
|
249
|
+
bootstrap.write_cache(fresh) if fresh
|
|
250
|
+
rescue StandardError
|
|
251
|
+
# Background refresh is best-effort
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
187
255
|
def setting_category(category)
|
|
188
256
|
@settings[category].map do |name, details|
|
|
189
257
|
details.merge(name: name.to_s)
|
|
@@ -217,7 +285,7 @@ module Legion
|
|
|
217
285
|
|
|
218
286
|
@settings[:api] ||= {}
|
|
219
287
|
@settings[:api][:port] = ENV['LEGION_API_PORT'].to_i
|
|
220
|
-
|
|
288
|
+
log_warn("using api port environment variable, api: #{@settings[:api]}")
|
|
221
289
|
@indifferent_access = false
|
|
222
290
|
end
|
|
223
291
|
|
|
@@ -287,20 +355,51 @@ module Legion
|
|
|
287
355
|
'unknown'
|
|
288
356
|
end
|
|
289
357
|
|
|
358
|
+
def log_debug(message)
|
|
359
|
+
Legion::Logging.debug(message) if defined?(Legion::Logging)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def log_warn(message)
|
|
363
|
+
defined?(Legion::Logging) ? Legion::Logging.warn(message) : warn(message)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def log_error(message)
|
|
367
|
+
defined?(Legion::Logging) ? Legion::Logging.error(message) : warn(message)
|
|
368
|
+
end
|
|
369
|
+
|
|
290
370
|
def warning(message, data = {})
|
|
291
371
|
@warnings << {
|
|
292
372
|
message: message
|
|
293
373
|
}.merge(data)
|
|
294
|
-
|
|
374
|
+
log_warn(message)
|
|
295
375
|
end
|
|
296
376
|
|
|
297
377
|
def load_error(message, data = {})
|
|
298
378
|
@errors << {
|
|
299
379
|
message: message
|
|
300
380
|
}.merge(data)
|
|
301
|
-
|
|
381
|
+
log_error(message)
|
|
302
382
|
raise(Error, message)
|
|
303
383
|
end
|
|
384
|
+
|
|
385
|
+
def read_resolv_config
|
|
386
|
+
config = Resolv::DNS::Config.default_config_hash
|
|
387
|
+
{
|
|
388
|
+
search_domains: config[:search]&.map(&:to_s)&.uniq,
|
|
389
|
+
nameservers: config[:nameserver]&.map(&:to_s)&.uniq
|
|
390
|
+
}
|
|
391
|
+
rescue StandardError
|
|
392
|
+
{ search_domains: [], nameservers: [] }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def detect_fqdn
|
|
396
|
+
fqdn = Addrinfo.getaddrinfo(Socket.gethostname, nil).first&.canonname
|
|
397
|
+
return nil if fqdn.nil?
|
|
398
|
+
|
|
399
|
+
fqdn.include?('.') ? fqdn : nil
|
|
400
|
+
rescue StandardError
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
304
403
|
end
|
|
305
404
|
end
|
|
306
405
|
end
|
data/lib/legion/settings.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Legion
|
|
|
17
17
|
def load(options = {})
|
|
18
18
|
@loader = Legion::Settings::Loader.new
|
|
19
19
|
@loader.load_env
|
|
20
|
+
@loader.load_dns_bootstrap
|
|
20
21
|
@loader.load_file(options[:config_file]) if options[:config_file]
|
|
21
22
|
@loader.load_directory(options[:config_dir]) if options[:config_dir]
|
|
22
23
|
options[:config_dirs]&.each do |directory|
|
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.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- docs/plans/2026-03-17-config-error-filename-implementation.md
|
|
47
47
|
- legion-settings.gemspec
|
|
48
48
|
- lib/legion/settings.rb
|
|
49
|
+
- lib/legion/settings/dns_bootstrap.rb
|
|
49
50
|
- lib/legion/settings/loader.rb
|
|
50
51
|
- lib/legion/settings/os.rb
|
|
51
52
|
- lib/legion/settings/resolver.rb
|