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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f66aca7decaa9e60f84abf3d4b2538f42a90978289916f535395991161bb8e24
4
- data.tar.gz: 41c5f1bac7ec1e12d0034ad872816405588d880542efbe7c420ec958697f1991
3
+ metadata.gz: 226a1ae2303f64b96fc48bd42101545a9d06b73aefab19278f27007c406c39d2
4
+ data.tar.gz: 4cbf2414166794436869ed44646f86c2860e7bdad4a07feebbd2536fe0e15276
5
5
  SHA512:
6
- metadata.gz: 967b78c6ca4e9e2bee7a2ac1e53c6198a0b0606985e16fca8734c061be6b5b5b4f30e7ed882c387af00554f3323e9008269e036eaec09e9b7ee12d1e1c8ad349
7
- data.tar.gz: 2451834575d97a57f1f11c41556f48cf581358624c793d8ac011158b174df2072988d1135af0b9fc3656d1fd608734ab155a2f5b7419d71f77a613e5d8677994
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
- Legion::Logging.debug("Trying to load file #{file}")
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
- Legion::Logging.error("config file must be valid json: #{file}")
142
- Legion::Logging.error(" parse error: #{e.message}")
176
+ log_error("config file must be valid json: #{file}")
177
+ log_error(" parse error: #{e.message}")
143
178
  end
144
179
  else
145
- Legion::Logging.warn("Config file does not exist or is not readable file:#{file}")
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
- Legion::Logging.warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
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
- Legion::Logging.warn("using api port environment variable, api: #{@settings[:api]}")
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
- Legion::Logging.warn(message)
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
- Legion::Logging.error(message)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.3.4'
5
+ VERSION = '1.3.6'
6
6
  end
7
7
  end
@@ -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
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