legion-settings 1.3.26 → 1.4.0

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.
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'extensions/store'
4
+ require_relative 'extensions/filter'
5
+ require_relative 'extensions/normalizer'
6
+
7
+ module Legion
8
+ module Settings
9
+ # Thread-safe runtime registry for extensions, runners, and tools.
10
+ #
11
+ # Used by the LegionIO boot pipeline to register discovered extensions,
12
+ # their runner modules, and individual tools. Consumers (legion-mcp,
13
+ # legion-llm, legion-rbac, API) read from this registry at runtime.
14
+ #
15
+ # Each store is a Concurrent::Map-backed Store instance. Read operations
16
+ # return frozen duplicates so callers cannot mutate registry internals.
17
+ module Extensions
18
+ @extension_store = Store.new
19
+ @runner_store = Store.new
20
+ @tool_store = Store.new
21
+
22
+ class << self
23
+ # ----------------------------------------------------------------
24
+ # Registration (called during LegionIO boot pipeline)
25
+ # ----------------------------------------------------------------
26
+
27
+ def register_extension(name, metadata = {})
28
+ normalized = Normalizer.normalize_extension(name, metadata)
29
+ @extension_store.register(name, normalized)
30
+ end
31
+
32
+ def register_runner(name, metadata = {})
33
+ normalized = Normalizer.normalize_runner(name, metadata)
34
+ @runner_store.register(name, normalized)
35
+ end
36
+
37
+ def register_tool(name, metadata = {})
38
+ normalized = Normalizer.normalize_tool(name, metadata)
39
+ @tool_store.register(name, normalized)
40
+ end
41
+
42
+ def transition(name, state, **extra)
43
+ @extension_store.update(name, state: state, transitioned_at: Time.now, **extra)
44
+ end
45
+
46
+ # ----------------------------------------------------------------
47
+ # Query (called by legion-mcp, legion-llm, legion-rbac, API)
48
+ # ----------------------------------------------------------------
49
+
50
+ def extensions
51
+ @extension_store.all
52
+ end
53
+
54
+ def runners
55
+ @runner_store.all
56
+ end
57
+
58
+ def tools
59
+ @tool_store.all
60
+ end
61
+
62
+ def find_extension(name)
63
+ @extension_store.find(name)
64
+ end
65
+
66
+ def find_runner(name)
67
+ @runner_store.find(name)
68
+ end
69
+
70
+ def find_tool(name)
71
+ @tool_store.find(name)
72
+ end
73
+
74
+ def filter_tools(**criteria)
75
+ entries = @tool_store.all.map(&:dup)
76
+ result = Filter.apply_tool_filters(entries, criteria, extension_store: @extension_store)
77
+ result.each(&:freeze)
78
+ result.freeze
79
+ end
80
+
81
+ def filter_extensions(**criteria)
82
+ entries = @extension_store.all.map(&:dup)
83
+ result = Filter.apply_extension_filters(entries, criteria)
84
+ result.each(&:freeze)
85
+ result.freeze
86
+ end
87
+
88
+ # ----------------------------------------------------------------
89
+ # Lifecycle
90
+ # ----------------------------------------------------------------
91
+
92
+ def unregister_extension(name)
93
+ removed = @extension_store.delete(name)
94
+ return nil unless removed
95
+
96
+ key = name.to_s
97
+ @runner_store.delete_where { |v| v[:extension].to_s == key }
98
+ @tool_store.delete_where { |v| v[:extension].to_s == key }
99
+ removed
100
+ end
101
+
102
+ def unregister_tool(name)
103
+ @tool_store.delete(name)
104
+ end
105
+
106
+ def reset!
107
+ @extension_store.clear
108
+ @runner_store.clear
109
+ @tool_store.clear
110
+ end
111
+
112
+ # ----------------------------------------------------------------
113
+ # Counts
114
+ # ----------------------------------------------------------------
115
+
116
+ def extension_count
117
+ @extension_store.size
118
+ end
119
+
120
+ def runner_count
121
+ @runner_store.size
122
+ end
123
+
124
+ def tool_count
125
+ @tool_store.size
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,41 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent/hash'
4
+
3
5
  module Legion
4
6
  module Settings
5
7
  module Helper
8
+ # Namespace boundary words — segment extraction stops at these.
9
+ # Matches LegionIO's Extensions::Helpers::Base::NAMESPACE_BOUNDARIES.
10
+ NAMESPACE_BOUNDARIES = %w[Actor Actors Runners Helpers Transport Data].freeze
11
+
12
+ # Returns the gem-level settings hash for this extension.
13
+ # Sub-modules (ConceptualBlending inside Agentic::Language) get
14
+ # the SAME hash as the root — they access their section via key:
15
+ # settings[:conceptual_blending]
16
+ #
17
+ # Path resolution uses segments derived from the class namespace:
18
+ # Legion::Extensions::Github → Settings[:extensions][:github]
19
+ # Legion::Extensions::Agentic::Learning → Settings[:extensions][:agentic][:learning]
20
+ # Legion::Extensions::MicrosoftTeams → Settings[:extensions][:microsoft_teams]
21
+ # Legion::Extensions::Llm::Openai → Settings[:extensions][:llm][:openai]
6
22
  def settings
7
- ext_key = derive_settings_key
8
- if Legion::Settings[:extensions]&.key?(ext_key)
9
- Legion::Settings[:extensions][ext_key]
10
- else
11
- {}
12
- end
23
+ segments = derive_settings_segments
24
+ dig_or_create(Legion::Settings[:extensions], segments)
13
25
  end
14
26
 
15
27
  private
16
28
 
17
- def derive_settings_key
18
- if respond_to?(:lex_filename)
19
- fname = lex_filename
20
- (fname.is_a?(Array) ? fname.first : fname).to_sym
21
- else
22
- derive_settings_key_from_class
23
- end
29
+ # Derives the gem-level segments from the class namespace.
30
+ # Stops at NAMESPACE_BOUNDARIES so sub-modules (Runners, Actors, etc.)
31
+ # resolve to their parent extension, not deeper.
32
+ #
33
+ # Legion::Extensions::Agentic::Learning::ConceptualBlending::Runners::Blend
34
+ # → ['agentic', 'learning'] (stops at ConceptualBlending because next is Runners)
35
+ #
36
+ # Legion::Extensions::Agentic::Learning::ConceptualBlending
37
+ # → ['agentic', 'learning'] (stops at ConceptualBlending — it's a sub-module, not a segment)
38
+ #
39
+ # Wait — ConceptualBlending IS a namespace part, not a boundary word.
40
+ # The gem is lex-agentic-learning → segments are ['agentic', 'learning'].
41
+ # ConceptualBlending is INSIDE the gem, not part of the gem name.
42
+ # So we need to know the gem's segment count to stop there.
43
+ #
44
+ # Strategy: if the caller responds to :segments (LegionIO's Base mixin),
45
+ # use those directly. Otherwise derive from namespace, stopping at
46
+ # boundary words or after 2 levels (covers most lex-X-Y patterns).
47
+ def derive_settings_segments
48
+ # Prefer explicit segments from LegionIO's Helpers::Base
49
+ return segments.map { |s| s.to_s.to_sym } if respond_to?(:segments)
50
+
51
+ derive_segments_from_class
24
52
  end
25
53
 
26
- def derive_settings_key_from_class
54
+ def derive_segments_from_class
27
55
  name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s
28
56
  parts = name.split('::')
29
57
  ext_idx = parts.index('Extensions')
30
- target = if ext_idx && parts[ext_idx + 1]
31
- parts[ext_idx + 1]
32
- else
33
- parts.last
34
- end
35
- target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
36
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
37
- .downcase
38
- .to_sym
58
+ return [camelize_to_snake(parts.last).to_sym] unless ext_idx
59
+
60
+ segment_parts = []
61
+ ((ext_idx + 1)...parts.length).each do |i|
62
+ break if NAMESPACE_BOUNDARIES.include?(parts[i])
63
+
64
+ segment_parts << camelize_to_snake(parts[i]).to_sym
65
+ end
66
+
67
+ # The gem-level segments are the parts between Extensions:: and
68
+ # the first sub-module that isn't part of the gem name.
69
+ # For lex-agentic-learning, gem segments = [:agentic, :learning].
70
+ # ConceptualBlending is a sub-module INSIDE the gem.
71
+ # We use the registered extension entry to find the correct depth,
72
+ # falling back to all segments if no registry entry exists.
73
+ resolve_gem_segments(segment_parts)
74
+ end
75
+
76
+ def resolve_gem_segments(all_segments)
77
+ return all_segments if all_segments.length <= 1
78
+
79
+ # Check if Settings::Extensions has this extension registered
80
+ # with known segments — use those as the authoritative gem boundary.
81
+ if defined?(Legion::Settings::Extensions)
82
+ # Try progressively shorter segment paths to find the registered gem
83
+ all_segments.length.downto(1) do |len|
84
+ candidate = all_segments[0, len]
85
+ gem_name = "lex-#{candidate.join('-')}"
86
+ entry = Legion::Settings::Extensions.find_extension(gem_name)
87
+ return candidate if entry
88
+ end
89
+ end
90
+
91
+ all_segments
92
+ end
93
+
94
+ # Digs into a nested hash using segments as keys, creating
95
+ # Concurrent::Hash at each level if missing.
96
+ def dig_or_create(root, segments)
97
+ return Concurrent::Hash.new unless root.is_a?(Hash)
98
+
99
+ segments.reduce(root) do |current, key|
100
+ if current.is_a?(Hash) && current.key?(key)
101
+ current[key]
102
+ elsif current.is_a?(Hash)
103
+ empty = Concurrent::Hash.new
104
+ current[key] = empty
105
+ empty
106
+ else
107
+ return Concurrent::Hash.new
108
+ end
109
+ end
110
+ end
111
+
112
+ def camelize_to_snake(str)
113
+ str.to_s
114
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
115
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
116
+ .downcase
39
117
  end
40
118
  end
41
119
  end
@@ -4,9 +4,11 @@ require 'resolv'
4
4
  require 'socket'
5
5
  require 'digest'
6
6
  require 'tmpdir'
7
+ require 'concurrent/hash'
7
8
  require 'legion/logging'
8
9
  require 'legion/settings/os'
9
10
  require_relative 'dns_bootstrap'
11
+ require_relative 'deep_merge'
10
12
 
11
13
  module Legion
12
14
  module Settings
@@ -15,7 +17,7 @@ module Legion
15
17
  include Legion::Logging::Helper
16
18
 
17
19
  class Error < RuntimeError; end
18
- attr_reader :warnings, :errors, :loaded_files, :settings
20
+ attr_reader :warnings, :errors, :loaded_files, :settings, :merged_modules
19
21
 
20
22
  def self.default_directories
21
23
  env_dirs = ENV.fetch('LEGION_SETTINGS_DIRS', nil)
@@ -40,13 +42,14 @@ module Legion
40
42
  @settings = default_settings
41
43
  @indifferent_access = false
42
44
  @loaded_files = []
45
+ @merged_modules = {}
43
46
  log.debug('Initialized Legion::Settings::Loader with default settings')
44
47
  end
45
48
 
46
49
  def dns_defaults
47
50
  resolv_config = read_resolv_config
48
51
  {
49
- fqdn: detect_fqdn,
52
+ fqdn: nil, # lazy — resolved on first access via resolve_fqdn!
50
53
  default_domain: resolv_config[:search_domains]&.first,
51
54
  search_domains: resolv_config[:search_domains] || [],
52
55
  nameservers: resolv_config[:nameservers] || [],
@@ -54,6 +57,15 @@ module Legion
54
57
  }
55
58
  end
56
59
 
60
+ # Lazily resolve the FQDN on first access instead of blocking at init.
61
+ #
62
+ # @return [String, nil] the fully qualified domain name, or nil
63
+ def resolve_fqdn!
64
+ return @settings[:dns][:fqdn] if @settings.dig(:dns, :fqdn)
65
+
66
+ @settings[:dns][:fqdn] = detect_fqdn
67
+ end
68
+
57
69
  def client_defaults
58
70
  {
59
71
  hostname: system_hostname,
@@ -63,64 +75,25 @@ module Legion
63
75
  }
64
76
  end
65
77
 
66
- def logging_defaults
67
- {
68
- level: 'info',
69
- format: 'text',
70
- log_file: './legionio/logs/legion.log',
71
- log_stdout: true,
72
- trace: true,
73
- async: true,
74
- include_pid: false,
75
- transport: {
76
- enabled: true,
77
- forward_logs: true,
78
- forward_exceptions: true
79
- }
80
- }
81
- end
82
-
83
- def absorbers_defaults
84
- {
85
- enabled: true,
86
- max_depth: 5,
87
- sources: {
88
- meetings: {
89
- enabled: true,
90
- include_chat: true,
91
- include_files: true,
92
- retention_days: 90,
93
- min_duration_min: 5
94
- },
95
- email_inbox: {
96
- enabled: false,
97
- folder: 'inbox',
98
- max_age_days: 30
99
- },
100
- github: {
101
- enabled: true,
102
- events: %w[pull_request issues]
103
- },
104
- files: {
105
- enabled: true,
106
- watch_dirs: [],
107
- extensions: %w[pdf docx txt md pptx rtf]
108
- }
109
- }
110
- }
111
- end
78
+ # No more per-module defaults methods in the Loader.
79
+ # Tier 1 deps (json, logging) are called directly in default_settings.
80
+ # Tier 2 libraries (transport, cache, etc.) self-register via
81
+ # Legion::Settings.register_library when they load.
112
82
 
113
83
  def default_settings
114
84
  {
85
+ # --- Tier 1: gemspec dependencies (always installed with legion-settings) ---
86
+ # legion-logging: always available, has Settings.default
87
+ logging: Legion::Logging::Settings.default,
88
+ # legion-json: always available, no Settings module yet — stub
89
+ # until legion-json adds Legion::JSON::Settings.default
90
+ json: Concurrent::Hash.new,
91
+
92
+ # --- Structural: owned by legion-settings itself ---
115
93
  client: client_defaults,
116
- cluster: { public_keys: {} },
117
- crypt: {
118
- cluster_secret: nil,
119
- cluster_secret_timeout: 5,
120
- vault: { connected: false }
121
- },
122
- cache: { enabled: true, connected: false, driver: 'dalli' },
123
- extensions: {
94
+ cluster: Concurrent::Hash.new,
95
+ dns: dns_defaults,
96
+ extensions: Concurrent::Hash[
124
97
  core: %w[
125
98
  lex-node lex-tasker lex-scheduler lex-health lex-ping
126
99
  lex-telemetry lex-metering lex-log lex-audit
@@ -139,20 +112,25 @@ module Legion
139
112
  reserved_words: %w[transport cache crypt data settings json logging llm rbac legion],
140
113
  agentic: { allowed: nil, blocked: [] },
141
114
  parallel_pool_size: 24
142
- },
115
+ ],
143
116
  reload: false,
144
117
  reloading: false,
145
118
  auto_install_missing_lex: true,
146
119
  default_extension_settings: {},
147
- logging: logging_defaults,
148
- absorbers: absorbers_defaults,
149
- transport: { connected: false },
150
- data: { connected: false },
151
120
  role: { profile: nil, extensions: [] },
152
121
  region: { current: nil, primary: nil, failover: nil, peers: [],
153
122
  default_affinity: 'any', data_residency: {} },
154
123
  process: { role: 'full' },
155
- dns: dns_defaults
124
+
125
+ # --- Tier 2: stubs for libraries that self-register via register_library ---
126
+ # These ensure Settings[:key] returns a hash (not nil) before
127
+ # the owning library loads. The library replaces these with its
128
+ # full defaults when it calls Legion::Settings.register_library.
129
+ absorbers: Concurrent::Hash.new,
130
+ cache: Concurrent::Hash.new,
131
+ crypt: Concurrent::Hash.new,
132
+ data: Concurrent::Hash.new,
133
+ transport: Concurrent::Hash.new
156
134
  }
157
135
  end
158
136
 
@@ -164,12 +142,26 @@ module Legion
164
142
  @settings
165
143
  end
166
144
 
145
+ # Direct key lookup — does NOT trigger indifferent_access! rebuild.
146
+ # This is the hot path called by every Settings[:key] access.
147
+ # Supports both symbol and string keys without converting the whole tree.
167
148
  def [](key)
168
- to_hash[key]
149
+ result = @settings[key]
150
+ return result unless result.nil? && key.is_a?(String)
151
+
152
+ @settings[key.to_sym]
169
153
  end
170
154
 
155
+ # Direct nested lookup — does NOT trigger indifferent_access! rebuild.
171
156
  def dig(*keys)
172
- to_hash.dig(*keys)
157
+ keys.reduce(self) do |current, key|
158
+ return nil unless current.respond_to?(:[])
159
+
160
+ value = current.is_a?(Loader) ? current[key] : (current[key] || current[key.to_s])
161
+ return nil if value.nil? && !current.is_a?(Loader)
162
+
163
+ value
164
+ end
173
165
  end
174
166
 
175
167
  def []=(key, value)
@@ -221,20 +213,21 @@ module Legion
221
213
 
222
214
  def load_module_settings(config)
223
215
  mod_name = config.keys.first
224
- log_debug("Loading module settings: #{mod_name}")
216
+ log.debug("Loading module settings: #{mod_name}")
217
+ @merged_modules = deep_merge(@merged_modules, config)
225
218
  @settings = deep_merge(config, @settings)
226
219
  mark_dirty!
227
220
  end
228
221
 
229
222
  def load_module_default(config)
230
223
  mod_name = config.keys.first
231
- log_debug("Loading module defaults: #{mod_name}")
224
+ log.debug("Loading module defaults: #{mod_name}")
232
225
  @settings = deep_merge(config, @settings)
233
226
  mark_dirty!
234
227
  end
235
228
 
236
229
  def load_file(file)
237
- log_debug("Trying to load file #{file}")
230
+ log.debug("Trying to load file #{file}")
238
231
  if File.file?(file) && File.readable?(file)
239
232
  begin
240
233
  contents = read_config_file(file)
@@ -244,11 +237,11 @@ module Legion
244
237
  @loaded_files << file
245
238
  log.debug("Loaded settings file #{file}")
246
239
  rescue Legion::JSON::ParseError => e
247
- log_error("config file must be valid json: #{file}")
248
- log_error(" parse error: #{e.message}")
240
+ log.error("config file must be valid json: #{file}")
241
+ log.error(" parse error: #{e.message}")
249
242
  end
250
243
  else
251
- log_warn("Config file does not exist or is not readable file:#{file}")
244
+ log.warn("Config file does not exist or is not readable file:#{file}")
252
245
  end
253
246
  end
254
247
 
@@ -257,7 +250,7 @@ module Legion
257
250
  if File.readable?(path) && File.executable?(path)
258
251
  files = Dir.glob(File.join(path, '**', '*.json'))
259
252
  files.each { |file| load_file(file) }
260
- log_info("Settings: loaded directory #{path} (#{files.size} files)")
253
+ log.info("Settings: loaded directory #{path} (#{files.size} files)")
261
254
  else
262
255
  load_error('insufficient permissions for loading', directory: directory)
263
256
  end
@@ -270,7 +263,7 @@ module Legion
270
263
  @settings[:client][:subscriptions].uniq!
271
264
  mark_dirty!
272
265
  else
273
- log_warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
266
+ log.warn('unable to apply legion client overrides, reason: client subscriptions is not an array')
274
267
  end
275
268
  end
276
269
 
@@ -302,7 +295,7 @@ module Legion
302
295
  end
303
296
 
304
297
  def load_dns_first_boot(bootstrap)
305
- log_debug("DNS bootstrap: first boot, fetching from #{bootstrap.url}")
298
+ log.debug("DNS bootstrap: first boot, fetching from #{bootstrap.url}")
306
299
  config = bootstrap.fetch
307
300
  bootstrap.write_cache(config) if config
308
301
  config
@@ -324,7 +317,7 @@ module Legion
324
317
  fresh = bootstrap.fetch
325
318
  bootstrap.write_cache(fresh) if fresh
326
319
  rescue StandardError => e
327
- log_warn("DNS background refresh failed: #{e.message}")
320
+ log.warn("DNS background refresh failed: #{e.message}")
328
321
  end
329
322
  end
330
323
 
@@ -361,7 +354,7 @@ module Legion
361
354
 
362
355
  @settings[:api] ||= {}
363
356
  @settings[:api][:port] = ENV['LEGION_API_PORT'].to_i
364
- log_warn("using api port environment variable, api: #{@settings[:api]}")
357
+ log.warn("using api port environment variable, api: #{@settings[:api]}")
365
358
  mark_dirty!
366
359
  end
367
360
 
@@ -374,29 +367,15 @@ module Legion
374
367
 
375
368
  def read_config_file(file)
376
369
  contents = File.read(file).dup
377
- if contents.respond_to?(:force_encoding)
378
- encoding = ::Encoding::ASCII_8BIT
379
- contents = contents.force_encoding(encoding)
380
- bom = (+"\xEF\xBB\xBF").force_encoding(encoding)
381
- contents.sub!(bom, '')
382
- else
383
- contents.sub!(/^\357\273\277/, '')
384
- end
370
+ encoding = ::Encoding::ASCII_8BIT
371
+ contents = contents.force_encoding(encoding)
372
+ bom = (+"\xEF\xBB\xBF").force_encoding(encoding)
373
+ contents.sub!(bom, '')
385
374
  contents.strip
386
375
  end
387
376
 
388
377
  def deep_merge(hash_one, hash_two)
389
- merged = hash_one.dup
390
- hash_two.each do |key, value|
391
- merged[key] = if hash_one[key].is_a?(Hash) && value.is_a?(Hash)
392
- deep_merge(hash_one[key], value)
393
- elsif hash_one[key].is_a?(Array) && value.is_a?(Array)
394
- hash_one[key].concat(value).uniq
395
- else
396
- value
397
- end
398
- end
399
- merged
378
+ DeepMerge.deep_merge(hash_one, hash_two)
400
379
  end
401
380
 
402
381
  def create_loaded_tempfile!
@@ -423,7 +402,7 @@ module Legion
423
402
  def system_hostname
424
403
  Socket.gethostname
425
404
  rescue StandardError => e
426
- log_debug("Legion::Settings::Loader#system_hostname failed: #{e.message}")
405
+ log.debug("Legion::Settings::Loader#system_hostname failed: #{e.message}")
427
406
  'unknown'
428
407
  end
429
408
 
@@ -432,7 +411,7 @@ module Legion
432
411
  preferred = addresses.find { |a| rfc1918?(a.ip_address) }
433
412
  (preferred || addresses.first)&.ip_address || 'unknown'
434
413
  rescue StandardError => e
435
- log_debug("Legion::Settings::Loader#system_address failed: #{e.message}")
414
+ log.debug("Legion::Settings::Loader#system_address failed: #{e.message}")
436
415
  'unknown'
437
416
  end
438
417
 
@@ -442,34 +421,18 @@ module Legion
442
421
  ip.start_with?('192.168.')
443
422
  end
444
423
 
445
- def log_info(message)
446
- log.info(message)
447
- end
448
-
449
- def log_debug(message)
450
- log.debug(message)
451
- end
452
-
453
- def log_warn(message)
454
- log.warn(message)
455
- end
456
-
457
- def log_error(message)
458
- log.error(message)
459
- end
460
-
461
424
  def warning(message, data = {})
462
425
  @warnings << {
463
426
  message: message
464
427
  }.merge(data)
465
- log_warn(message)
428
+ log.warn(message)
466
429
  end
467
430
 
468
431
  def load_error(message, data = {})
469
432
  @errors << {
470
433
  message: message
471
434
  }.merge(data)
472
- log_error(message)
435
+ log.error(message)
473
436
  raise(Error, message)
474
437
  end
475
438
 
@@ -480,7 +443,7 @@ module Legion
480
443
  nameservers: config[:nameserver]&.map(&:to_s)&.uniq
481
444
  }
482
445
  rescue StandardError => e
483
- log_warn("Failed to read resolv config: #{e.message}")
446
+ log.warn("Failed to read resolv config: #{e.message}")
484
447
  { search_domains: [], nameservers: [] }
485
448
  end
486
449
 
@@ -491,10 +454,10 @@ module Legion
491
454
 
492
455
  fqdn.include?('.') ? fqdn : nil
493
456
  rescue Timeout::Error
494
- log_debug('FQDN detection skipped (DNS timeout)')
457
+ log.debug('FQDN detection skipped (DNS timeout)')
495
458
  nil
496
459
  rescue StandardError => e
497
- log_debug("FQDN detection skipped (#{e.message.split(':').first})")
460
+ log.debug("FQDN detection skipped (#{e.message.split(':').first})")
498
461
  nil
499
462
  end
500
463
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/settings/deep_merge'
4
+
3
5
  module Legion
4
6
  module Settings
5
7
  # Thread-local request-scoped settings overlay.
@@ -38,6 +40,13 @@ module Legion
38
40
  Thread.current[THREAD_KEY]
39
41
  end
40
42
 
43
+ # Returns true when a thread-local overlay is active.
44
+ #
45
+ # @return [Boolean]
46
+ def active?
47
+ !Thread.current[THREAD_KEY].nil?
48
+ end
49
+
41
50
  # Clear the thread-local overlay for the current thread.
42
51
  def clear_overlay!
43
52
  Thread.current[THREAD_KEY] = nil
@@ -61,16 +70,7 @@ module Legion
61
70
  private
62
71
 
63
72
  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
73
+ DeepMerge.deep_merge(base, overrides)
74
74
  end
75
75
  end
76
76
  end