legionio 1.4.109 → 1.4.111

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: 52e1d5b8749747a9fe4b89302f1e0122f86a7630e02f668dfa5df8de363399d7
4
- data.tar.gz: 315b433465e06726391616a01d0cd6d8d8b13c849eff241a2702cdb8ab66cbaa
3
+ metadata.gz: fa14ee5aba79a712b64bd58ff53a40076fd8c4d3f84d6b58757f393175aba7f2
4
+ data.tar.gz: 572ed5d1c54e429da795341b6aa725c9187c06e4dca4445eff530d59f3324f5e
5
5
  SHA512:
6
- metadata.gz: 4b8c67b2e70b786fc16abb5f43ce8a4d8155e87b22fcf4e901afb24c021fc2112b07b884b75e16f9a48e046a959cacc035885d4251f4812e3eb805f2465e66f6
7
- data.tar.gz: 79b46b3605b2c249090b11dd4d12db71ff523061b9f9ae929cca2d7a1b3646489c3afb01f8dd36d6dcc532d925c86b2b48c6b90ee9cbac39c5bbdee5514618c7
6
+ metadata.gz: 3eb840d78c0218c25882e8f3e757164ca7463b73f246aec0ac41a99c3c54ab3e644c5f59ee0fd695c0ab31818043f976f765c1316930a4ae86985179f92b69ba
7
+ data.tar.gz: 338a32c3e8e50b6f4113144b90daf0508a95032ddf3c393317ef1f431671db2d1caf32caf94c5e870181c5cf05e6a483a329bfdb636eacd2b6a4ce646ab34a8b
data/.rubocop.yml CHANGED
@@ -46,6 +46,7 @@ Metrics/BlockLength:
46
46
  - 'lib/legion/cli/notebook_command.rb'
47
47
  - 'lib/legion/api/acp.rb'
48
48
  - 'lib/legion/api/auth_saml.rb'
49
+ - 'lib/legion/cli/failover_command.rb'
49
50
 
50
51
  Metrics/AbcSize:
51
52
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.111] - 2026-03-21
4
+
5
+ ### Added
6
+ - Register logging hooks in boot sequence: fatal/error/warn published to `legion.logging` RMQ exchange
7
+ - Routing key pattern: `legion.<source>.<level>` (e.g., `legion.core.fatal`, `legion.lex-slack.error`)
8
+ - `Legion::Region` module: cloud metadata detection (AWS IMDSv2, Azure IMDS), region affinity routing
9
+ - `Legion::Region::Failover`: promote regions with replication lag checks, --dry-run, --force
10
+ - `legion failover` CLI: promote and status subcommands for region failover management
11
+
12
+ ## [1.4.110] - 2026-03-21
13
+
14
+ ### Added
15
+ - Domain restrictions in extension Sandbox (allowed_domains on Policy, domain_allowed? check)
16
+ - Sandbox.allowed? class method for combined capability + domain checks
17
+
3
18
  ## [1.4.109] - 2026-03-21
4
19
 
5
20
  ### Added
data/CODEOWNERS ADDED
@@ -0,0 +1 @@
1
+ * @Esity
data/Gemfile CHANGED
@@ -4,6 +4,8 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__))
8
+
7
9
  gem 'kramdown', '>= 2.0'
8
10
  gem 'mysql2'
9
11
 
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'legion/cli/output'
5
+ require 'legion/cli/connection'
6
+
7
+ module Legion
8
+ module CLI
9
+ class Failover < Thor
10
+ namespace 'failover'
11
+
12
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+
15
+ desc 'promote', 'Promote a region to primary'
16
+ option :region, type: :string, required: true, desc: 'Target region to promote'
17
+ option :dry_run, type: :boolean, default: false, desc: 'Show replication lag without promoting'
18
+ option :force, type: :boolean, default: false, desc: 'Force promotion even if lag exceeds threshold'
19
+ def promote
20
+ out = formatter
21
+ ensure_settings
22
+
23
+ target = options[:region]
24
+ require 'legion/region/failover'
25
+
26
+ if options[:dry_run]
27
+ run_dry_run(out, target)
28
+ else
29
+ run_promote(out, target)
30
+ end
31
+ rescue Legion::Region::Failover::UnknownRegionError => e
32
+ out.error(e.message)
33
+ raise SystemExit, 1
34
+ rescue Legion::Region::Failover::LagTooHighError => e
35
+ if options[:force]
36
+ out.warn("#{e.message} — forcing promotion")
37
+ force_promote(out, target)
38
+ else
39
+ out.error("#{e.message}. Use --force to override.")
40
+ raise SystemExit, 1
41
+ end
42
+ end
43
+
44
+ desc 'status', 'Show current region configuration'
45
+ def status
46
+ out = formatter
47
+ ensure_settings
48
+
49
+ region_config = Legion::Settings[:region] || {}
50
+ if options[:json]
51
+ out.json(region_config)
52
+ else
53
+ out.header('Region Configuration')
54
+ out.detail({
55
+ current: region_config[:current] || '(not set)',
56
+ primary: region_config[:primary] || '(not set)',
57
+ failover: region_config[:failover] || '(not set)',
58
+ peers: (region_config[:peers] || []).join(', ').then { |s| s.empty? ? '(none)' : s },
59
+ default_affinity: region_config[:default_affinity] || 'prefer_local'
60
+ })
61
+ end
62
+ end
63
+
64
+ no_commands do
65
+ def formatter
66
+ @formatter ||= Output::Formatter.new(
67
+ json: options[:json],
68
+ color: !options[:no_color]
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def ensure_settings
75
+ Connection.ensure_settings
76
+ end
77
+
78
+ def run_dry_run(out, target)
79
+ Legion::Region::Failover.validate_target!(target)
80
+ lag = Legion::Region::Failover.replication_lag
81
+
82
+ if options[:json]
83
+ out.json({ target: target, lag_seconds: lag, dry_run: true })
84
+ else
85
+ out.header('Failover Dry Run')
86
+ lag_str = lag ? "#{lag.round(1)}s" : '(unavailable — no DB connection)'
87
+ out.detail({ target: target, replication_lag: lag_str })
88
+ if lag && lag > Legion::Region::Failover::MAX_LAG_SECONDS
89
+ out.warn("Lag exceeds #{Legion::Region::Failover::MAX_LAG_SECONDS}s threshold")
90
+ else
91
+ out.success('Lag within acceptable range')
92
+ end
93
+ end
94
+ end
95
+
96
+ def run_promote(out, target)
97
+ result = Legion::Region::Failover.promote!(region: target)
98
+ if options[:json]
99
+ out.json(result)
100
+ else
101
+ out.success("Region promoted: #{result[:previous]} -> #{result[:promoted]}")
102
+ lag_str = result[:lag_seconds] ? "#{result[:lag_seconds].round(1)}s" : '(unavailable)'
103
+ out.detail({ promoted: result[:promoted], previous: result[:previous], replication_lag: lag_str })
104
+ end
105
+ end
106
+
107
+ def force_promote(out, target)
108
+ previous = Legion::Settings.dig(:region, :primary)
109
+ lag = Legion::Region::Failover.replication_lag
110
+ Legion::Settings[:region][:primary] = target
111
+ Legion::Events.emit('region.failover', from: previous, to: target) if defined?(Legion::Events)
112
+
113
+ result = { promoted: target, previous: previous, lag_seconds: lag, forced: true }
114
+ if options[:json]
115
+ out.json(result)
116
+ else
117
+ out.success("Region force-promoted: #{previous} -> #{target}")
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
data/lib/legion/cli.rb CHANGED
@@ -54,6 +54,7 @@ module Legion
54
54
  autoload :Payroll, 'legion/cli/payroll_command'
55
55
  autoload :Interactive, 'legion/cli/interactive'
56
56
  autoload :Docs, 'legion/cli/docs_command'
57
+ autoload :Failover, 'legion/cli/failover_command'
57
58
 
58
59
  class Main < Thor
59
60
  def self.exit_on_failure?
@@ -282,6 +283,9 @@ module Legion
282
283
  desc 'docs SUBCOMMAND', 'Documentation site generator'
283
284
  subcommand 'docs', Legion::CLI::Docs
284
285
 
286
+ desc 'failover SUBCOMMAND', 'Region failover management'
287
+ subcommand 'failover', Legion::CLI::Failover
288
+
285
289
  desc 'tree', 'Print a tree of all available commands'
286
290
  def tree
287
291
  legion_print_command_tree(self.class, 'legion', '')
@@ -112,6 +112,19 @@ module Legion
112
112
 
113
113
  message = process_message(payload, metadata, delivery_info)
114
114
  fn = find_function(message)
115
+
116
+ affinity_result = check_region_affinity(message)
117
+ if affinity_result == :reject
118
+ Legion::Logging.warn "Rejecting message: region affinity mismatch (region=#{message[:region]}, affinity=#{message[:region_affinity]})"
119
+ @queue.reject(delivery_info.delivery_tag) if manual_ack
120
+ next
121
+ end
122
+
123
+ if affinity_result == :remote
124
+ Legion::Logging.debug 'Processing remote-region message ' \
125
+ "(region=#{message[:region]}, affinity=#{message[:region_affinity]})"
126
+ end
127
+
115
128
  if use_runner?
116
129
  dispatch_runner(message, runner_class, fn, check_subtask?, generate_task?)
117
130
  else
@@ -131,6 +144,14 @@ module Legion
131
144
 
132
145
  private
133
146
 
147
+ def check_region_affinity(message)
148
+ return :local unless defined?(Legion::Region)
149
+
150
+ region = message[:region]
151
+ affinity = message[:region_affinity]
152
+ Legion::Region.affinity_for(region, affinity)
153
+ end
154
+
134
155
  def dispatch_runner(message, runner_cls, function, check_subtask, generate_task)
135
156
  run_block = lambda {
136
157
  Legion::Runner.run(**message,
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Region
5
+ module Failover
6
+ module_function
7
+
8
+ MAX_LAG_SECONDS = 30
9
+
10
+ def promote!(region:)
11
+ validate_target!(region)
12
+
13
+ lag = replication_lag
14
+ raise LagTooHighError, "replication lag #{lag.round(1)}s exceeds #{MAX_LAG_SECONDS}s threshold" if lag && lag > MAX_LAG_SECONDS
15
+
16
+ previous = Legion::Settings.dig(:region, :primary)
17
+ Legion::Settings[:region][:primary] = region
18
+ Legion::Events.emit('region.failover', from: previous, to: region) if defined?(Legion::Events)
19
+
20
+ { promoted: region, previous: previous, lag_seconds: lag }
21
+ end
22
+
23
+ def replication_lag
24
+ return nil unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
25
+
26
+ row = Legion::Data.connection.fetch(
27
+ 'SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS lag'
28
+ ).first
29
+ row[:lag]&.to_f
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ def validate_target!(region)
35
+ peers = Legion::Settings.dig(:region, :peers) || []
36
+ failover = Legion::Settings.dig(:region, :failover)
37
+ known = (peers + [failover].compact).uniq
38
+
39
+ return if known.include?(region)
40
+
41
+ raise UnknownRegionError, "'#{region}' is not a known peer or failover region (known: #{known.join(', ')})"
42
+ end
43
+
44
+ class LagTooHighError < StandardError; end
45
+ class UnknownRegionError < StandardError; end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Legion
6
+ module Region
7
+ module_function
8
+
9
+ def current
10
+ setting = defined?(Legion::Settings) ? Legion::Settings.dig(:region, :current) : nil
11
+ setting || detect_from_metadata
12
+ rescue StandardError
13
+ nil
14
+ end
15
+
16
+ def local?(target_region)
17
+ target_region.nil? || target_region == current
18
+ end
19
+
20
+ def affinity_for(message_region, affinity)
21
+ return :local if local?(message_region) || affinity == 'any'
22
+ return :remote if affinity == 'prefer_local'
23
+ return :reject if affinity == 'require_local'
24
+
25
+ :local
26
+ end
27
+
28
+ def primary
29
+ return nil unless defined?(Legion::Settings)
30
+
31
+ Legion::Settings.dig(:region, :primary)
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ def failover
37
+ return nil unless defined?(Legion::Settings)
38
+
39
+ Legion::Settings.dig(:region, :failover)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def peers
45
+ return [] unless defined?(Legion::Settings)
46
+
47
+ Legion::Settings.dig(:region, :peers) || []
48
+ rescue StandardError
49
+ []
50
+ end
51
+
52
+ def detect_from_metadata
53
+ detect_aws_region || detect_azure_region
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def detect_aws_region
59
+ uri = URI('http://169.254.169.254/latest/meta-data/placement/region')
60
+ token_uri = URI('http://169.254.169.254/latest/api/token')
61
+
62
+ token = Net::HTTP.start(token_uri.host, token_uri.port, open_timeout: 1, read_timeout: 1) do |http|
63
+ req = Net::HTTP::Put.new(token_uri)
64
+ req['X-aws-ec2-metadata-token-ttl-seconds'] = '21600'
65
+ http.request(req).body
66
+ end
67
+
68
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
69
+ req = Net::HTTP::Get.new(uri)
70
+ req['X-aws-ec2-metadata-token'] = token
71
+ response = http.request(req)
72
+ response.is_a?(Net::HTTPSuccess) ? response.body.strip : nil
73
+ end
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ def detect_azure_region
79
+ uri = URI('http://169.254.169.254/metadata/instance/compute/location?api-version=2021-02-01&format=text')
80
+
81
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
82
+ req = Net::HTTP::Get.new(uri)
83
+ req['Metadata'] = 'true'
84
+ response = http.request(req)
85
+ response.is_a?(Net::HTTPSuccess) ? response.body.strip : nil
86
+ end
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ end
91
+ end
@@ -12,23 +12,31 @@ module Legion
12
12
  transport:publish transport:subscribe
13
13
  ].freeze
14
14
 
15
- attr_reader :extension_name, :capabilities
15
+ attr_reader :extension_name, :capabilities, :allowed_domains
16
16
 
17
- def initialize(extension_name:, capabilities: [])
17
+ def initialize(extension_name:, capabilities: [], allowed_domains: nil)
18
18
  @extension_name = extension_name
19
19
  @capabilities = capabilities.select { |c| CAPABILITIES.include?(c) }.freeze
20
+ @allowed_domains = allowed_domains&.map(&:to_s)&.freeze
20
21
  end
21
22
 
22
23
  def allowed?(capability)
23
24
  capabilities.include?(capability.to_s)
24
25
  end
26
+
27
+ def domain_allowed?(agent_domain)
28
+ return true if allowed_domains.nil? || allowed_domains.empty?
29
+
30
+ allowed_domains.include?(agent_domain.to_s)
31
+ end
25
32
  end
26
33
 
27
34
  class << self
28
- def register_policy(extension_name, capabilities:)
35
+ def register_policy(extension_name, capabilities:, allowed_domains: nil)
29
36
  policies[extension_name] = Policy.new(
30
- extension_name: extension_name,
31
- capabilities: capabilities
37
+ extension_name: extension_name,
38
+ capabilities: capabilities,
39
+ allowed_domains: allowed_domains
32
40
  )
33
41
  end
34
42
 
@@ -45,6 +53,19 @@ module Legion
45
53
  true
46
54
  end
47
55
 
56
+ def allowed?(extension_name: nil, gem_name: nil, capability: nil, agent_domain: nil)
57
+ ext = extension_name || gem_name
58
+ return true unless enforcement_enabled?
59
+
60
+ policy = policy_for(ext)
61
+
62
+ return false if capability && !policy.allowed?(capability)
63
+
64
+ return false if agent_domain && !policy.domain_allowed?(agent_domain)
65
+
66
+ true
67
+ end
68
+
48
69
  def enforcement_enabled?
49
70
  return false unless defined?(Legion::Settings)
50
71
 
@@ -45,6 +45,7 @@ module Legion
45
45
  if transport
46
46
  setup_transport
47
47
  Legion::Readiness.mark_ready(:transport)
48
+ register_logging_hooks
48
49
  end
49
50
 
50
51
  if cache
@@ -250,6 +251,27 @@ module Legion
250
251
  Legion::Transport::Connection.setup
251
252
  end
252
253
 
254
+ def register_logging_hooks
255
+ return unless Legion::Transport::Connection.session_open?
256
+
257
+ exchange = Legion::Transport::Exchanges::Logging.new
258
+
259
+ %i[fatal error warn].each do |level|
260
+ Legion::Logging.send(:"on_#{level}") do |event|
261
+ next unless Legion::Transport::Connection.session_open?
262
+
263
+ source = event[:lex] || 'core'
264
+ routing_key = "legion.#{source}.#{level}"
265
+ exchange.publish(Legion::JSON.dump(event), routing_key: routing_key)
266
+ rescue StandardError
267
+ nil
268
+ end
269
+ end
270
+
271
+ Legion::Logging.enable_hooks!
272
+ Legion::Logging.info('Logging hooks registered for RMQ publishing')
273
+ end
274
+
253
275
  def setup_alerts
254
276
  enabled = begin
255
277
  Legion::Settings[:alerts][:enabled]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.109'
4
+ VERSION = '1.4.111'
5
5
  end
data/lib/legion.rb CHANGED
@@ -12,6 +12,8 @@ require 'legion/service'
12
12
  require 'legion/extensions'
13
13
 
14
14
  module Legion
15
+ autoload :Region, 'legion/region'
16
+
15
17
  attr_reader :service
16
18
 
17
19
  def self.start
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.109
4
+ version: 1.4.111
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -335,6 +335,7 @@ files:
335
335
  - ".rubocop.yml"
336
336
  - CHANGELOG.md
337
337
  - CLAUDE.md
338
+ - CODEOWNERS
338
339
  - Dockerfile
339
340
  - Gemfile
340
341
  - LICENSE
@@ -498,6 +499,7 @@ files:
498
499
  - lib/legion/cli/doctor_command.rb
499
500
  - lib/legion/cli/error.rb
500
501
  - lib/legion/cli/eval_command.rb
502
+ - lib/legion/cli/failover_command.rb
501
503
  - lib/legion/cli/function.rb
502
504
  - lib/legion/cli/gaia_command.rb
503
505
  - lib/legion/cli/generate_command.rb
@@ -653,6 +655,8 @@ files:
653
655
  - lib/legion/process.rb
654
656
  - lib/legion/process_role.rb
655
657
  - lib/legion/readiness.rb
658
+ - lib/legion/region.rb
659
+ - lib/legion/region/failover.rb
656
660
  - lib/legion/registry.rb
657
661
  - lib/legion/registry/governance.rb
658
662
  - lib/legion/registry/persistence.rb