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.
- checksums.yaml +4 -4
- data/.gitignore +2 -1
- data/.pre-commit-config.yaml +29 -0
- data/CHANGELOG.md +31 -0
- data/README.md +46 -1
- data/legion-settings.gemspec +1 -0
- data/lib/legion/settings/agent_loader.rb +2 -10
- data/lib/legion/settings/deep_merge.rb +53 -0
- data/lib/legion/settings/extensions/filter.rb +104 -0
- data/lib/legion/settings/extensions/normalizer.rb +235 -0
- data/lib/legion/settings/extensions/store.rb +80 -0
- data/lib/legion/settings/extensions.rb +130 -0
- data/lib/legion/settings/helper.rb +101 -23
- data/lib/legion/settings/loader.rb +80 -117
- data/lib/legion/settings/overlay.rb +10 -10
- data/lib/legion/settings/project_env.rb +5 -19
- data/lib/legion/settings/resolver.rb +15 -27
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +161 -15
- data/scripts/pre-commit-rubocop.sh +39 -0
- metadata +22 -1
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
240
|
+
log.error("config file must be valid json: #{file}")
|
|
241
|
+
log.error(" parse error: #{e.message}")
|
|
249
242
|
end
|
|
250
243
|
else
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
+
log.debug('FQDN detection skipped (DNS timeout)')
|
|
495
458
|
nil
|
|
496
459
|
rescue StandardError => e
|
|
497
|
-
|
|
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
|
-
|
|
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
|