legionio 1.7.19 → 1.7.21

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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Legion
6
+ module Identity
7
+ class LeaseRenewer
8
+ attr_reader :provider_name
9
+
10
+ BACKOFF_SLEEP = 5
11
+ MIN_SLEEP = 1
12
+ DEFAULT_SLEEP = 60
13
+
14
+ def initialize(provider_name:, provider:, lease:)
15
+ @provider_name = provider_name
16
+ @provider = provider
17
+ @lease = Concurrent::AtomicReference.new(lease)
18
+ @stop = Concurrent::AtomicBoolean.new(false)
19
+ @thread = Thread.new { run_loop }
20
+ @thread.name = "lease-renewer-#{provider_name}"
21
+ @thread.abort_on_exception = false
22
+ end
23
+
24
+ def current_lease
25
+ @lease.get
26
+ end
27
+
28
+ def stop!
29
+ @stop.make_true
30
+ @thread&.wakeup rescue nil # rubocop:disable Style/RescueModifier
31
+ @thread&.join(5)
32
+ end
33
+
34
+ def alive?
35
+ @thread&.alive? || false
36
+ end
37
+
38
+ private
39
+
40
+ def run_loop
41
+ until @stop.true?
42
+ lease = @lease.get
43
+ sleep_time = compute_sleep(lease)
44
+ interruptible_sleep(sleep_time)
45
+ break if @stop.true?
46
+
47
+ renew
48
+ end
49
+ end
50
+
51
+ def renew
52
+ new_lease = @provider.provide_token
53
+ @lease.set(new_lease) if new_lease&.valid?
54
+ rescue StandardError => e
55
+ log_renewal_failure(e)
56
+ interruptible_sleep(BACKOFF_SLEEP)
57
+ end
58
+
59
+ def compute_sleep(lease)
60
+ return DEFAULT_SLEEP if lease.nil? || lease.expires_at.nil? || lease.issued_at.nil?
61
+
62
+ remaining = lease.expires_at - Time.now
63
+ half_remaining = remaining / 2.0
64
+ [half_remaining, MIN_SLEEP].max
65
+ end
66
+
67
+ def interruptible_sleep(seconds)
68
+ deadline = Time.now + seconds
69
+ sleep([1, deadline - Time.now].min) while Time.now < deadline && !@stop.true?
70
+ end
71
+
72
+ def log_renewal_failure(error)
73
+ message = "[LeaseRenewer][#{@provider_name}] renewal failed: #{error.message}"
74
+ if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
75
+ Legion::Logging.warn(message)
76
+ else
77
+ $stderr.puts message # rubocop:disable Style/StderrPuts
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Middleware
6
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze
7
+ LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze
8
+
9
+ def initialize(app, require_auth: false)
10
+ @app = app
11
+ @require_auth = require_auth
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) if skip_path?(env['PATH_INFO'])
16
+
17
+ # Bridge from existing auth middleware
18
+ auth_claims = env['legion.auth']
19
+ auth_method = env['legion.auth_method']
20
+
21
+ env['legion.principal'] = if auth_claims
22
+ build_request(auth_claims, auth_method)
23
+ elsif @require_auth
24
+ # Auth middleware already handled 401 for protected paths;
25
+ # this is a safety net for any path that slipped through.
26
+ nil
27
+ else
28
+ # No auth required (loopback bind, lite mode, etc.).
29
+ # Set a system-level principal so audit trails always have an identity.
30
+ system_principal
31
+ end
32
+
33
+ @app.call(env)
34
+ end
35
+
36
+ # Returns whether the API should require authentication.
37
+ # Skips auth for lite mode and loopback binds (local dev / CI).
38
+ def self.require_auth?(bind:, mode:)
39
+ return false if mode == :lite
40
+ return false if LOOPBACK_BINDS.include?(bind)
41
+
42
+ true
43
+ end
44
+
45
+ private
46
+
47
+ def skip_path?(path)
48
+ SKIP_PATHS.any? { |p| path.start_with?(p) }
49
+ end
50
+
51
+ def build_request(claims, method)
52
+ Identity::Request.from_auth_context({
53
+ sub: claims[:sub] || claims[:worker_id] || claims[:owner_msid],
54
+ name: claims[:name] || claims[:sub],
55
+ kind: determine_kind(claims, method),
56
+ groups: Array(claims[:roles] || claims[:groups]),
57
+ source: method&.to_sym
58
+ })
59
+ end
60
+
61
+ def determine_kind(claims, method)
62
+ return :service if claims[:scope] == 'worker' || claims[:worker_id]
63
+ return :human if method == 'kerberos' || claims[:scope] == 'human'
64
+
65
+ :human
66
+ end
67
+
68
+ def system_principal
69
+ @system_principal ||= Identity::Request.new(
70
+ principal_id: 'system:local',
71
+ canonical_name: 'system',
72
+ kind: :service,
73
+ groups: [],
74
+ source: :local
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'concurrent/atomic/atomic_reference'
5
+ require 'concurrent/atomic/atomic_boolean'
6
+
7
+ module Legion
8
+ module Identity
9
+ module Process
10
+ EMPTY_STATE = {
11
+ id: nil,
12
+ canonical_name: nil,
13
+ kind: nil,
14
+ persistent: false,
15
+ groups: [].freeze,
16
+ metadata: {}.freeze
17
+ }.freeze
18
+
19
+ class << self
20
+ def id
21
+ state = @state.get
22
+ state[:id] || Legion.instance_id
23
+ end
24
+
25
+ def canonical_name
26
+ state = @state.get
27
+ state[:canonical_name] || 'anonymous'
28
+ end
29
+
30
+ def kind
31
+ @state.get[:kind]
32
+ end
33
+
34
+ def mode
35
+ Legion::Mode.current
36
+ end
37
+
38
+ def queue_prefix
39
+ name = canonical_name
40
+ case mode
41
+ when :worker then "worker.#{name}.#{Legion.instance_id}"
42
+ when :infra then "infra.#{name}.#{safe_hostname}"
43
+ when :lite then "lite.#{name}.#{Legion.instance_id}"
44
+ else "agent.#{name}.#{safe_hostname}"
45
+ end
46
+ end
47
+
48
+ def resolved?
49
+ @resolved.true?
50
+ end
51
+
52
+ def persistent?
53
+ @state.get[:persistent] == true
54
+ end
55
+
56
+ def identity_hash
57
+ {
58
+ id: id,
59
+ canonical_name: canonical_name,
60
+ kind: kind,
61
+ mode: mode,
62
+ queue_prefix: queue_prefix,
63
+ resolved: resolved?,
64
+ persistent: persistent?,
65
+ groups: @state.get[:groups] || [],
66
+ metadata: @state.get[:metadata] || {}
67
+ }
68
+ end
69
+
70
+ def bind!(provider, identity_hash)
71
+ @provider = provider
72
+ @state.set({
73
+ id: identity_hash[:id],
74
+ canonical_name: identity_hash[:canonical_name],
75
+ kind: identity_hash[:kind],
76
+ persistent: identity_hash.fetch(:persistent, true),
77
+ groups: Array(identity_hash[:groups]).compact.freeze,
78
+ metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze
79
+ })
80
+ @resolved.make_true
81
+ end
82
+
83
+ def bind_fallback!
84
+ user = ENV.fetch('USER', 'anonymous')
85
+ @state.set({
86
+ id: nil,
87
+ canonical_name: user,
88
+ kind: :human,
89
+ persistent: false,
90
+ groups: [].freeze,
91
+ metadata: {}.freeze
92
+ })
93
+ @resolved.make_false
94
+ end
95
+
96
+ def refresh_credentials
97
+ return unless defined?(@provider) && @provider.respond_to?(:refresh)
98
+
99
+ @provider.refresh
100
+ end
101
+
102
+ def reset!
103
+ @state = Concurrent::AtomicReference.new(EMPTY_STATE.dup)
104
+ @resolved = Concurrent::AtomicBoolean.new(false)
105
+ @provider = nil
106
+ end
107
+
108
+ private
109
+
110
+ def safe_hostname
111
+ ::Socket.gethostname.downcase
112
+ .gsub(/[^a-z0-9]+/, '-')
113
+ .gsub(/\A-+|-+\z/, '')
114
+ end
115
+ end
116
+
117
+ # Initialize atomics at module definition time
118
+ reset!
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Request
6
+ attr_reader :principal_id, :canonical_name, :kind, :groups, :source, :metadata
7
+
8
+ def initialize(principal_id:, canonical_name:, kind:, groups: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
9
+ @principal_id = principal_id
10
+ @canonical_name = canonical_name
11
+ @kind = kind
12
+ @groups = groups.freeze
13
+ @source = source
14
+ @metadata = metadata.freeze
15
+ freeze
16
+ end
17
+
18
+ # Reads the already-resolved identity from the Rack env (set by middleware).
19
+ # Returns nil when the key is absent.
20
+ def self.from_env(env)
21
+ env['legion.principal']
22
+ end
23
+
24
+ # Builds a Request from a parsed auth claims hash with symbol keys:
25
+ # { sub:, name:, preferred_username:, kind:, groups:, source: }
26
+ def self.from_auth_context(claims_hash)
27
+ raw_name = claims_hash[:name] || claims_hash[:preferred_username] || ''
28
+ canonical = raw_name.to_s.strip.downcase.gsub('.', '-')
29
+
30
+ new(
31
+ principal_id: claims_hash[:sub],
32
+ canonical_name: canonical,
33
+ kind: claims_hash[:kind] || :human,
34
+ groups: claims_hash[:groups] || [],
35
+ source: claims_hash[:source]
36
+ )
37
+ end
38
+
39
+ def identity_hash
40
+ {
41
+ principal_id: principal_id,
42
+ canonical_name: canonical_name,
43
+ kind: kind,
44
+ groups: groups,
45
+ source: source
46
+ }
47
+ end
48
+
49
+ # Maps to RBAC principal format.
50
+ # :service workers are represented as :worker in RBAC.
51
+ def to_rbac_principal
52
+ {
53
+ identity: canonical_name,
54
+ type: kind == :service ? :worker : kind
55
+ }
56
+ end
57
+
58
+ # Pipeline-compatible caller hash (matches legion-llm pipeline format).
59
+ def to_caller_hash
60
+ {
61
+ requested_by: {
62
+ id: principal_id,
63
+ identity: canonical_name,
64
+ type: kind,
65
+ credential: source
66
+ }
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Mode
5
+ LEGACY_MAP = { full: :agent, api: :worker, router: :worker, worker: :worker, lite: :lite }.freeze
6
+
7
+ class << self
8
+ def current
9
+ raw = ENV['LEGION_MODE'] ||
10
+ settings_dig(:mode) ||
11
+ settings_dig(:process, :mode) ||
12
+ legacy_role
13
+ normalize(raw)
14
+ end
15
+
16
+ def agent?
17
+ current == :agent
18
+ end
19
+
20
+ def worker?
21
+ current == :worker
22
+ end
23
+
24
+ def infra?
25
+ current == :infra
26
+ end
27
+
28
+ def lite?
29
+ current == :lite
30
+ end
31
+
32
+ private
33
+
34
+ def normalize(raw)
35
+ return :agent if raw.nil?
36
+
37
+ sym = raw.to_s.downcase.strip.to_sym
38
+ return sym if %i[agent worker infra lite].include?(sym)
39
+
40
+ LEGACY_MAP.fetch(sym, :agent)
41
+ end
42
+
43
+ def legacy_role
44
+ settings_dig(:process, :role)
45
+ end
46
+
47
+ def fetch_setting_value(container, key)
48
+ value = container[key]
49
+ return value unless value.nil?
50
+
51
+ alternate_key = case key
52
+ when Symbol then key.to_s
53
+ when String then key.to_sym
54
+ end
55
+ return value if alternate_key.nil?
56
+
57
+ container[alternate_key]
58
+ end
59
+
60
+ def settings_dig(*keys)
61
+ return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
62
+
63
+ result = Legion::Settings
64
+ keys.each do |k|
65
+ return nil unless result.respond_to?(:[])
66
+
67
+ result = fetch_setting_value(result, k)
68
+ return nil if result.nil? && keys.last != k
69
+ end
70
+ result
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,10 +4,12 @@ module Legion
4
4
  module ProcessRole
5
5
  ROLES = {
6
6
  full: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true },
7
+ agent: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true },
7
8
  api: { transport: true, cache: true, data: true, extensions: false, api: true, llm: false, gaia: false, crypt: true, supervision: false },
8
9
  worker: { transport: true, cache: true, data: true, extensions: true, api: false, llm: true, gaia: true, crypt: true, supervision: true },
9
10
  router: { transport: true, cache: true, data: false, extensions: true, api: false, llm: false, gaia: false, crypt: true, supervision: false },
10
- lite: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: false, supervision: true }
11
+ lite: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: false, supervision: true },
12
+ infra: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true }
11
13
  }.freeze
12
14
 
13
15
  def self.resolve(role_name)
@@ -21,7 +23,7 @@ module Legion
21
23
 
22
24
  def self.current
23
25
  settings = begin
24
- Legion::Settings[:process]
26
+ defined?(Legion::Settings) ? Legion::Settings[:process] : nil
25
27
  rescue StandardError => e
26
28
  Legion::Logging.debug "ProcessRole#current failed to read process settings: #{e.message}" if defined?(Legion::Logging)
27
29
  nil
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
4
+
3
5
  module Legion
4
6
  module Readiness
5
- COMPONENTS = %i[settings crypt transport cache data rbac llm apollo gaia extensions api].freeze
7
+ REQUIRED_COMPONENTS = %i[settings crypt transport cache data extensions api].freeze
8
+ OPTIONAL_COMPONENTS = %i[rbac llm apollo gaia identity].freeze
9
+ COMPONENTS = (REQUIRED_COMPONENTS + OPTIONAL_COMPONENTS).freeze
6
10
  DRAIN_TIMEOUT = 5
7
11
 
8
12
  class << self
9
13
  def status
10
- @status ||= {}
14
+ @status ||= Concurrent::Hash.new
11
15
  end
12
16
 
13
17
  def mark_ready(component)
@@ -20,14 +24,19 @@ module Legion
20
24
  Legion::Logging.debug "[Readiness] #{component} is not ready" if defined?(Legion::Logging)
21
25
  end
22
26
 
27
+ def mark_skipped(component)
28
+ status[component.to_sym] = :skipped
29
+ Legion::Logging.debug "[Readiness] #{component} skipped (optional)" if defined?(Legion::Logging)
30
+ end
31
+
23
32
  def ready?(component = nil)
24
33
  if component
25
- result = status[component.to_sym] == true
34
+ result = [true, :skipped].include?(status[component.to_sym])
26
35
  Legion::Logging.warn "[Readiness] #{component} is not ready" if !result && defined?(Legion::Logging)
27
36
  return result
28
37
  end
29
38
 
30
- not_ready = COMPONENTS.reject { |c| status[c] == true }
39
+ not_ready = COMPONENTS.reject { |c| [true, :skipped].include?(status[c]) }
31
40
  not_ready.each { |c| Legion::Logging.warn "[Readiness] #{c} is not ready" } if !not_ready.empty? && defined?(Legion::Logging)
32
41
  not_ready.empty?
33
42
  end
@@ -43,12 +52,13 @@ module Legion
43
52
  end
44
53
 
45
54
  def reset
46
- @status = {}
55
+ @status = nil
47
56
  end
48
57
 
49
58
  def to_h
50
59
  COMPONENTS.to_h do |c|
51
- [c, status[c] == true]
60
+ val = status[c]
61
+ [c, [true, :skipped].include?(val)]
52
62
  end
53
63
  end
54
64
  end