legion-crypt 1.5.13 → 1.5.38

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.
@@ -3,6 +3,7 @@
3
3
  require 'legion/logging/helper'
4
4
  require 'singleton'
5
5
  require 'timeout'
6
+ require 'legion/crypt/vault_operation'
6
7
 
7
8
  module Legion
8
9
  module Crypt
@@ -11,188 +12,192 @@ module Legion
11
12
  include Legion::Logging::Helper
12
13
 
13
14
  RENEWAL_CHECK_INTERVAL = 5
15
+ MINIMUM_SLEEP = 0.01
16
+ MAX_RETRY_DELAY = 60
14
17
 
15
18
  def initialize
16
19
  @lease_cache = {}
17
20
  @active_leases = {}
21
+ @owned_leases = {}
22
+ @pending = {}
18
23
  @refs = {}
24
+ @ref_bindings = {}
25
+ @installations = {}.compare_by_identity
26
+ @lease_locks = {}
27
+ @state_mutex = Mutex.new
28
+ @wake = ConditionVariable.new
19
29
  @running = false
20
30
  @renewal_thread = nil
21
- @state_mutex = Mutex.new
31
+ @generation = 0
32
+ @stopped = false
33
+ @renewal_generation = nil
22
34
  end
23
35
 
24
36
  def start(definitions, vault_client: nil)
25
- @state_mutex.synchronize { @vault_client = vault_client }
26
- return if definitions.nil? || definitions.empty?
37
+ client = vault_client || Legion::Crypt.vault_client
38
+ generation = @state_mutex.synchronize do
39
+ return if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
27
40
 
41
+ @stopped = false
42
+ @vault_client = client
43
+ @generation
44
+ end
28
45
  register_at_exit_hook
46
+ (definitions || {}).each do |name, opts|
47
+ break unless lifecycle_current?(generation: generation)
29
48
 
30
- log.info "LeaseManager start requested definitions=#{definitions.size}"
31
- definitions.each do |name, opts|
49
+ name = name.to_sym
32
50
  path = opts['path'] || opts[:path]
33
51
  next unless path
34
52
 
35
- if lease_valid?(name)
36
- log.debug("LeaseManager: reusing valid cached lease for '#{name}'")
37
- next
38
- end
39
-
40
- revoke_expired_lease(name)
41
-
42
- begin
43
- log.info("LeaseManager: fetching lease '#{name}' from #{path}")
44
- response = logical.read(path)
45
- unless response
46
- log.warn("LeaseManager: no data at '#{name}' (#{path}) — path may not exist or role not configured")
47
- next
48
- end
53
+ pending = @state_mutex.synchronize { @pending[name] }
54
+ next if lease_valid?(name, path: path, client: client)
55
+ next if pending && pending[:path] == path && pending[:client].equal?(client) && pending[:next_attempt_at] > monotonic_now
49
56
 
50
- log_lease_response(name, response)
51
- cache_lease(name, response, path: path)
52
- log.info("LeaseManager: fetched lease '#{name}' from #{path} " \
53
- "(lease_id=#{response.lease_id.to_s[0..11]}... ttl=#{response.lease_duration}s renewable=#{response.renewable?})")
54
- rescue StandardError => e
55
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.start', lease_name: name, path: path)
56
- log.warn("LeaseManager: failed to fetch lease '#{name}' from #{path}: #{e.message}")
57
- end
57
+ acquire_lease(name: name, path: path, client: client, generation: generation)
58
58
  end
59
59
  end
60
60
 
61
61
  def fetched_count
62
- @state_mutex.synchronize { @active_leases.size }
62
+ @state_mutex.synchronize { @active_leases.count { |name, _lease| cached_lease_data(name: name) } }
63
63
  end
64
64
 
65
65
  def fetch(name, key)
66
- data = @state_mutex.synchronize do
67
- @lease_cache[name.to_sym] || @lease_cache[name.to_s]
68
- end
69
- return nil unless data
66
+ data = lease_data(name)
67
+ return unless data
70
68
 
71
- data[key.to_sym] || data[key.to_s]
69
+ data.key?(key.to_sym) ? data[key.to_sym] : data[key.to_s]
72
70
  end
73
71
 
74
72
  def lease_data(name)
75
- @state_mutex.synchronize { @lease_cache[name] }
73
+ @state_mutex.synchronize { cached_lease_data(name: name.to_sym) }
76
74
  end
77
75
 
78
76
  attr_reader :active_leases
79
77
 
80
- def register_ref(name, key, path)
81
- @state_mutex.synchronize do
78
+ def register_ref(name, key, path, generation: @generation)
79
+ return false unless path.is_a?(Array) && !path.empty?
80
+
81
+ name = name.to_sym
82
+ key = key.to_sym
83
+ with_current_state(generation: generation) do
84
+ @refs.delete_if do |registered_name, keys|
85
+ keys.delete_if do |registered_key, destinations|
86
+ @ref_bindings.delete(path) if !(registered_name == name && registered_key == key) && destinations.delete(path)
87
+ destinations.empty?
88
+ end
89
+ keys.empty?
90
+ end
82
91
  @refs[name] ||= {}
83
- @refs[name][key] = path
92
+ destinations = (@refs[name][key] ||= [])
93
+ destinations << path.dup.freeze unless destinations.include?(path)
84
94
  end
85
95
  end
86
96
 
87
- def push_to_settings(name)
88
- refs, data = @state_mutex.synchronize do
89
- r = @refs[name] || @refs[name.to_s] || @refs[name.to_sym]
90
- d = @lease_cache[name] || @lease_cache[name.to_s] || @lease_cache[name.to_sym]
91
- [r&.dup, d&.dup]
97
+ # Preparation and publication must only perform in-memory work, without calling manager methods.
98
+ def update_settings_refs(settings:, publish: nil)
99
+ raise ArgumentError, 'Settings reference preparation requires a block' unless block_given?
100
+
101
+ @state_mutex.synchronize do
102
+ raise 'Cannot publish settings from an in-flight lease reconnect' if @installations.value?(Thread.current)
103
+
104
+ generation = @generation
105
+ @wake.wait(@state_mutex) while !@installations.empty? && lifecycle_current?(generation: generation)
106
+ raise 'Cannot update settings references in an inactive lifecycle' unless lifecycle_current?(generation: generation)
107
+
108
+ reader = lambda do |name:, key:|
109
+ data = cached_lease_data(name: name.to_sym)
110
+ data && (data.key?(key.to_sym) ? data[key.to_sym] : data[key.to_s])
111
+ end
112
+ proposal = yield reader
113
+ apply_settings_proposal(settings: settings, proposal: proposal, publish: publish)
114
+ proposal
92
115
  end
93
- return if refs.nil? || refs.empty?
94
- return unless data
116
+ end
117
+
118
+ def push_to_settings(name, generation: @generation, active: nil)
119
+ count = with_current_state(generation: generation, active: active) do
120
+ refs = @refs[name.to_sym]
121
+ data = cached_lease_data(name: name.to_sym)
122
+ next 0 if refs.nil? || refs.empty? || data.nil?
95
123
 
96
- refs.each do |key, path|
97
- value = data[key.to_sym] || data[key.to_s]
98
- write_setting(path, value)
124
+ refs.sum do |key, paths|
125
+ next 0 unless data.key?(key) || data.key?(key.to_s)
126
+
127
+ value = data.key?(key) ? data[key] : data[key.to_s]
128
+ paths.count { |path| write_setting(path, value) }
129
+ end
99
130
  end
131
+ return unless count
100
132
 
101
- log.info("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
133
+ log.info("Lease '#{name}' rotated — updated #{count} settings reference(s)")
134
+ count
102
135
  end
103
136
 
104
- # Public Vault client accessors used by Crypt for bootstrap/swap operations.
105
- # Delegates to the configured vault_client or falls back to ::Vault.
106
137
  def vault_logical
107
- logical
138
+ client = @state_mutex.synchronize { @vault_client } || Legion::Crypt.vault_client
139
+ client.logical
108
140
  end
109
141
 
110
142
  def vault_sys
111
- sys
143
+ client = @state_mutex.synchronize { @vault_client } || Legion::Crypt.vault_client
144
+ client.sys
112
145
  end
113
146
 
114
- def reissue_all
115
- log.info('LeaseManager: reissue_all — re-issuing all active leases under new token')
116
- lease_names = @state_mutex.synchronize { @active_leases.keys.dup }
147
+ def reissue_all(active: nil)
148
+ names, generation = @state_mutex.synchronize { [@active_leases.keys, @generation] }
149
+ names.each do |name|
150
+ break unless lifecycle_current?(generation: generation, active: active)
117
151
 
118
- lease_names.each do |name|
119
- lease = @state_mutex.synchronize { @active_leases[name]&.dup }
120
- next unless lease && lease[:path]
121
-
122
- reissue_lease(name)
152
+ reissue_lease(name, generation: generation, active: active)
123
153
  end
124
- log.info('LeaseManager: reissue_all complete')
125
154
  end
126
155
 
127
156
  def register_dynamic_lease(name:, path:, response:, settings_refs:)
157
+ name = name.to_sym
158
+ generation, client = @state_mutex.synchronize { [@generation, @vault_client] }
159
+ client ||= Legion::Crypt.vault_client
128
160
  register_at_exit_hook
129
-
130
- @state_mutex.synchronize do
131
- @lease_cache[name] = response.data || {}
132
- @active_leases[name] = {
133
- lease_id: response.lease_id,
134
- lease_duration: response.lease_duration,
135
- expires_at: Time.now + (response.lease_duration || 0),
136
- fetched_at: Time.now,
137
- renewable: response.renewable?,
138
- path: path
139
- }
161
+ ownership = track_ownership(name: name, client: client, lease_id: response.lease_id)
162
+ install_started = false
163
+ with_lease_operation(name: name) do |deadline|
164
+ settings_refs.each { |ref| register_ref(name, ref[:key], ref[:path], generation: generation) }
165
+ install_started = true
166
+ install_lease(name: name, path: path, response: response, client: client, generation: generation,
167
+ deadline: deadline, reconnect: true, ownership: ownership)
140
168
  end
141
- settings_refs.each do |ref|
142
- register_ref(name, ref[:key], ref[:path])
169
+ rescue StandardError => e
170
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.register_dynamic_lease', lease_name: name)
171
+ unless install_started
172
+ @state_mutex.synchronize do
173
+ entry = @owned_leases[ownership]
174
+ entry[:retired] = true if entry && @active_leases[name]&.dig(:ownership) != ownership
175
+ end
143
176
  end
144
- log.info("LeaseManager: registered dynamic lease '#{name}' (path: #{path})")
177
+ schedule_retry(name: name, path: path, client: client, generation: generation)
178
+ false
145
179
  end
146
180
 
147
- def reissue_lease(name)
181
+ def reissue_lease(name, generation: @generation, active: nil, deadline: nil)
182
+ name = name.to_sym
148
183
  lease = @state_mutex.synchronize { @active_leases[name]&.dup }
149
- unless lease && lease[:path]
150
- log.warn("LeaseManager: cannot reissue lease '#{name}' — no path stored for re-read")
151
- return
152
- end
153
-
154
- log.info("LeaseManager: reissuing lease '#{name}' from #{lease[:path]}")
155
- response = logical.read(lease[:path])
156
- unless response&.data
157
- log.warn("LeaseManager: reissue for '#{name}' returned no data from #{lease[:path]}")
158
- return
159
- end
160
-
161
- updated = @state_mutex.synchronize do
162
- active_lease = @active_leases[name]
163
- next false unless active_lease
164
-
165
- @lease_cache[name] = response.data
166
- active_lease.merge!(
167
- lease_id: response.lease_id,
168
- lease_duration: response.lease_duration,
169
- expires_at: Time.now + (response.lease_duration || 0),
170
- fetched_at: Time.now,
171
- renewable: response.renewable?
172
- )
173
- true
174
- end
175
- unless updated
176
- log.warn("LeaseManager: reissue for '#{name}' skipped — lease was removed during reissue (likely shutdown)")
177
- return
178
- end
184
+ return false unless lease && lease[:path] && lifecycle_current?(generation: generation, active: active)
179
185
 
180
- lease_id_preview = response.lease_id.to_s[0..11]
181
- log.info("LeaseManager: reissued lease '#{name}' " \
182
- "(new_lease_id=#{lease_id_preview}... ttl=#{response.lease_duration}s)")
183
- push_to_settings(name)
184
- trigger_reconnect(name)
185
- rescue StandardError => e
186
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.reissue_lease', lease_name: name)
187
- log.warn("LeaseManager: failed to reissue lease '#{name}': #{e.message}")
186
+ acquire_lease(name: name, path: lease[:path], client: lease[:client], generation: generation,
187
+ active: active, deadline: deadline, lease: lease)
188
188
  end
189
189
 
190
190
  def start_renewal_thread
191
+ previous = @state_mutex.synchronize { @renewal_thread if @renewal_generation != @generation }
192
+ Legion::Crypt::VaultOperation.with_timeout { previous.join } if previous&.alive?
191
193
  @state_mutex.synchronize do
192
- return if @renewal_thread&.alive?
194
+ return if @renewal_thread&.alive? || @stopped
195
+ return if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
193
196
 
194
197
  @running = true
195
- @renewal_thread = Thread.new { renewal_loop }
198
+ generation = @generation
199
+ @renewal_generation = generation
200
+ @renewal_thread = Thread.new { renewal_loop(generation: generation) }
196
201
  end
197
202
  log.info 'LeaseManager renewal thread started'
198
203
  end
@@ -201,324 +206,634 @@ module Legion
201
206
  @state_mutex.synchronize { @renewal_thread&.alive? || false }
202
207
  end
203
208
 
204
- def shutdown
205
- log.info 'LeaseManager shutdown requested'
206
- stop_renewal_thread
207
-
208
- leases = @state_mutex.synchronize { @active_leases.dup }
209
- leases.each do |name, meta|
210
- lease_id = meta[:lease_id]
211
- next if lease_id.nil? || lease_id.empty?
212
-
213
- begin
214
- sys.revoke(lease_id)
215
- log.debug("LeaseManager: revoked lease '#{name}' (#{lease_id})")
216
- rescue StandardError => e
217
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.shutdown', lease_name: name)
218
- log.warn("LeaseManager: failed to revoke lease '#{name}' (#{lease_id}): #{e.message}")
219
- end
209
+ def request_stop
210
+ @state_mutex.synchronize do
211
+ @stopped = true
212
+ @running = false
213
+ @generation += 1
214
+ @lease_locks.clear
215
+ @wake.broadcast
216
+ @renewal_thread
220
217
  end
218
+ end
221
219
 
220
+ def shutdown(deadline: nil)
221
+ deadline ||= monotonic_now + Legion::Crypt::VaultOperation::SHUTDOWN_TIMEOUT
222
+ request_stop
222
223
  @state_mutex.synchronize do
223
- @lease_cache.clear
224
224
  @active_leases.clear
225
+ @lease_cache.clear
225
226
  @refs.clear
227
+ @ref_bindings.clear
228
+ @pending.clear
226
229
  @vault_client = nil
230
+ @owned_leases.each_value { |entry| entry[:retired] = true }
227
231
  end
232
+ stop_renewal_thread(deadline: deadline)
233
+ retire_unused_leases(deadline: deadline, force: true)
228
234
  log.info 'LeaseManager shutdown complete'
229
235
  end
230
236
 
231
237
  def reset!
238
+ stop_renewal_thread
232
239
  @state_mutex.synchronize do
233
- @running = false
234
240
  @lease_cache.clear
235
241
  @active_leases.clear
242
+ @owned_leases.clear
243
+ @pending.clear
236
244
  @refs.clear
245
+ @ref_bindings.clear
237
246
  @vault_client = nil
238
- @renewal_thread = nil
247
+ @stopped = false
239
248
  end
240
249
  end
241
250
 
242
251
  private
243
252
 
253
+ def apply_settings_proposal(settings:, proposal:, publish:)
254
+ validate_settings_proposal!(settings: settings, proposal: proposal)
255
+ references, bindings = settings_reference_registry(settings: settings, proposal: proposal)
256
+ assignments = proposal[:assignments].map do |assignment|
257
+ [assignment[:container], assignment[:key], assignment[:container][assignment[:key]], assignment[:value]]
258
+ end
259
+ binding_values = proposal[:references].map { |reference| [reference[:binding], reference[:binding][:value]] }
260
+ previous_refs = @refs
261
+ previous_bindings = @ref_bindings
262
+ begin
263
+ assignments.each { |container, key, _previous, value| container[key] = value }
264
+ @refs = references
265
+ @ref_bindings = bindings
266
+ publish_settings_proposal(settings: settings, proposal: proposal, publish: publish)
267
+ rescue StandardError
268
+ @refs = previous_refs
269
+ @ref_bindings = previous_bindings
270
+ assignments.reverse_each { |container, key, previous, _value| container[key] = previous }
271
+ binding_values.each { |binding, previous| binding[:value] = previous }
272
+ raise
273
+ end
274
+ end
275
+
276
+ def settings_reference_registry(settings:, proposal:)
277
+ paths = proposal[:paths]
278
+ references = @refs.each_with_object({}) do |(name, keys), result|
279
+ remaining = keys.transform_values { |destinations| destinations.reject { |path| paths.include?(path) } }
280
+ remaining.reject! { |_key, destinations| destinations.empty? }
281
+ result[name] = remaining unless remaining.empty?
282
+ end
283
+ bindings = @ref_bindings.except(*paths)
284
+ proposal[:references].each do |reference|
285
+ name = reference[:name].to_sym
286
+ key = reference[:key].to_sym
287
+ path = reference[:path].dup.freeze
288
+ references[name] ||= {}
289
+ (references[name][key] ||= []) << path
290
+ bindings[path] = { settings: settings, binding: reference[:binding] }
291
+ end
292
+ [references, bindings]
293
+ end
294
+
295
+ def publish_settings_proposal(settings:, proposal:, publish:)
296
+ proposal[:references].each do |reference|
297
+ target, key = setting_target(path: reference[:path], settings: settings)
298
+ raise ArgumentError, 'Settings assignment removed a reference destination' unless target
299
+
300
+ reference[:binding][:value] = target[key]
301
+ end
302
+ publish&.call(proposal)
303
+ return unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader&.settings.equal?(settings)
304
+
305
+ proposal[:references].each { |reference| @ref_bindings[reference[:path]][:settings] = nil }
306
+ end
307
+
308
+ def validate_settings_proposal!(settings:, proposal:)
309
+ raise ArgumentError, 'Settings root must be a Hash or Array' unless settings.is_a?(Hash) || settings.is_a?(Array)
310
+ unless proposal.is_a?(Hash) && %i[paths assignments references].all? { |key| proposal[key].is_a?(Array) }
311
+ raise ArgumentError, 'Invalid settings reference proposal'
312
+ end
313
+
314
+ proposal[:paths].each do |path|
315
+ unless path.is_a?(Array) && !path.empty? && path.all? { |part| part.is_a?(Symbol) || (part.is_a?(Integer) && part >= 0) }
316
+ raise ArgumentError, 'Invalid typed settings path'
317
+ end
318
+ end
319
+ validate_settings_assignments!(assignments: proposal[:assignments])
320
+ validate_settings_references!(settings: settings, paths: proposal[:paths], references: proposal[:references])
321
+ end
322
+
323
+ def validate_settings_assignments!(assignments:)
324
+ destinations = {}
325
+ assignments.each do |assignment|
326
+ raise ArgumentError, 'Invalid settings assignment' unless assignment.is_a?(Hash) && assignment.key?(:value)
327
+
328
+ container = assignment[:container]
329
+ key = assignment[:key]
330
+ raise ArgumentError, 'Unwritable settings assignment' unless valid_setting_key?(container: container, key: key) && !container.frozen?
331
+ raise ArgumentError, 'Duplicate settings assignment' if destinations[[container.object_id, key]]
332
+
333
+ destinations[[container.object_id, key]] = true
334
+ end
335
+ end
336
+
337
+ def validate_settings_references!(settings:, paths:, references:)
338
+ destinations = {}
339
+ binding_ids = {}.compare_by_identity
340
+ references.each do |reference|
341
+ unless reference.is_a?(Hash) && paths.include?(reference[:path]) &&
342
+ [reference[:name], reference[:key]].all? { |value| value.is_a?(String) || value.is_a?(Symbol) }
343
+ raise ArgumentError, 'Invalid settings reference'
344
+ end
345
+
346
+ target, key = setting_target(path: reference[:path], settings: settings)
347
+ raise ArgumentError, 'Unwritable settings reference destination' unless target
348
+ raise ArgumentError, 'Ambiguous settings reference destination' if destinations[[target.object_id, key]]
349
+
350
+ binding = reference[:binding]
351
+ unless binding.is_a?(Hash) && !binding.frozen? && binding.key?(:value) && !binding_ids[binding]
352
+ raise ArgumentError, 'Settings references require distinct mutable binding records'
353
+ end
354
+
355
+ destinations[[target.object_id, key]] = true
356
+ binding_ids[binding] = true
357
+ end
358
+ end
359
+
244
360
  def register_at_exit_hook
245
361
  return if @at_exit_registered
246
362
 
247
363
  at_exit do
248
- next if @state_mutex.synchronize { @active_leases.empty? }
364
+ next if @state_mutex.synchronize { @owned_leases.empty? }
249
365
 
250
- Timeout.timeout(10) { shutdown }
251
- rescue Timeout::Error => e
252
- log.warn("[LeaseManager] at_exit shutdown timed out after 10s: #{e.message}")
253
- rescue StandardError => e # best effort on crash
254
- log.warn("[LeaseManager] at_exit shutdown failed: #{e.class}: #{e.message}")
366
+ shutdown
367
+ rescue StandardError => e
368
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.at_exit')
255
369
  end
256
370
  @at_exit_registered = true
257
371
  end
258
372
 
259
- def cache_lease(name, response, path: nil)
260
- @state_mutex.synchronize do
261
- @lease_cache[name] = response.data || {}
262
- @active_leases[name] = {
263
- lease_id: response.lease_id,
264
- lease_duration: response.lease_duration,
265
- renewable: response.renewable?,
266
- expires_at: Time.now + (response.lease_duration || 0),
267
- fetched_at: Time.now,
268
- path: path
269
- }
373
+ def monotonic_now
374
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
375
+ end
376
+
377
+ def with_lease_operation(name:, deadline: nil, **)
378
+ lock = @state_mutex.synchronize { @lease_locks[name] ||= Mutex.new }
379
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) do |operation_deadline|
380
+ lock.synchronize { yield operation_deadline }
270
381
  end
271
382
  end
272
383
 
273
- def log_lease_response(name, response)
274
- data_keys = response.data&.keys&.map(&:to_s) || []
275
- log.debug("LeaseManager[#{name}]: lease_id=#{response.lease_id}, " \
276
- "lease_duration=#{response.lease_duration}s, " \
277
- "renewable=#{response.renewable?}, " \
278
- "data_keys=#{data_keys.inspect}")
279
- return unless response.data&.key?(:username)
384
+ def lease_operation_current?(name:, pending: nil, lease: nil, **)
385
+ @state_mutex.synchronize do
386
+ next false if pending && !@pending[name].equal?(pending)
280
387
 
281
- log.debug("LeaseManager[#{name}]: username=#{response.data[:username]}, " \
282
- "password_length=#{response.data[:password]&.length || 0}, " \
283
- "vhost=#{response.data[:vhost] || 'N/A'}, " \
284
- "tags=#{response.data[:tags] || 'N/A'}")
388
+ lease.nil? || @active_leases.dig(name, :ownership) == lease[:ownership]
389
+ end
285
390
  end
286
391
 
287
- def logical
288
- client = @state_mutex.synchronize { @vault_client }
289
- client ? client.logical : ::Vault.logical
290
- end
392
+ def acquire_lease(name:, path:, client:, generation:, **options)
393
+ active = options[:active]
394
+ deadline = options[:deadline]
395
+ with_lease_operation(name: name, deadline: deadline) do |operation_deadline|
396
+ return false unless lifecycle_current?(generation: generation, active: active)
397
+ return false unless lease_operation_current?(name: name, pending: options[:pending], lease: options[:lease])
398
+ raise ArgumentError, 'Lease acquisition requires a path' unless path
291
399
 
292
- def sys
293
- client = @state_mutex.synchronize { @vault_client }
294
- client ? client.sys : ::Vault.sys
295
- end
400
+ response = client.logical.read(path: path)
401
+ raise "Vault returned no lease for #{name}" unless response
296
402
 
297
- def stop_renewal_thread
298
- thread = @state_mutex.synchronize do
299
- @running = false
300
- @renewal_thread
403
+ install_lease(name: name, path: path, response: response, client: client, generation: generation,
404
+ active: active, deadline: operation_deadline)
301
405
  end
302
- return unless thread
406
+ rescue StandardError => e
407
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.acquire_lease', lease_name: name, path: path)
408
+ schedule_retry(name: name, path: path, client: client, generation: generation, active: active,
409
+ pending: options[:pending], lease: options[:lease])
410
+ false
411
+ end
303
412
 
413
+ def track_ownership(name:, client:, lease_id:, **)
414
+ return if lease_id.nil? || lease_id.empty?
415
+
416
+ key = [client, lease_id]
417
+ @state_mutex.synchronize do
418
+ @owned_leases[key] ||= { name: name, client: client, lease_id: lease_id, retired: false,
419
+ revoking: false, attempts: 0, next_attempt_at: 0 }
420
+ end
421
+ key
422
+ end
423
+
424
+ def install_lease(name:, path:, response:, client:, **context)
425
+ lease_id = context.fetch(:lease_id) { response.lease_id }
426
+ ownership = context.fetch(:ownership) { track_ownership(name: name, client: client, lease_id: lease_id) }
427
+ handoff = { name: name, ownership: ownership, context: context }
428
+ now = Time.now
429
+ ttl = response.lease_duration || 0
430
+ candidate = { lease_id: lease_id, lease_duration: ttl, renewable: response.renewable?,
431
+ expires_at: ttl.positive? ? now + ttl : nil, fetched_at: now, path: path,
432
+ client: client, ownership: ownership }
433
+ handoff[:candidate] = candidate
434
+ handoff[:data] = context.fetch(:data) { response.data }
435
+ return false unless stage_install(handoff: handoff)
436
+
437
+ generation = context.fetch(:generation)
438
+ active = context[:active]
439
+ handoff[:attempted_reconnect] = handoff[:reconnect]
440
+ raise "Lease consumer reconnect failed for #{name}" if handoff[:reconnect] && !trigger_reconnect(name, generation: generation, active: active)
441
+
442
+ committed = with_current_state(generation: generation, active: active) do
443
+ next false unless @active_leases[name].equal?(candidate)
444
+
445
+ @pending.delete(name)
446
+ @owned_leases.each do |key, entry|
447
+ entry[:retired] = true if entry[:name] == name && key != ownership
448
+ end
449
+ @wake.broadcast
450
+ true
451
+ end
452
+ committed
453
+ ensure
304
454
  begin
305
- thread.wakeup if thread.alive?
306
- rescue ThreadError => e
307
- handle_exception(e, level: :debug, operation: 'crypt.lease_manager.stop_renewal_thread')
455
+ abort_install(handoff: handoff) if handoff && !committed
456
+ ensure
457
+ @state_mutex.synchronize do
458
+ @installations.delete(candidate)
459
+ @wake.broadcast
460
+ end
308
461
  end
309
- thread.join(2)
310
- if thread.alive?
311
- log.warn 'LeaseManager renewal thread did not stop within timeout'
312
- else
313
- @state_mutex.synchronize { @renewal_thread = nil }
314
- log.debug 'LeaseManager renewal thread stopped'
462
+ retire_unused_leases(name: name, deadline: context[:deadline])
463
+ end
464
+
465
+ def stage_install(handoff:, **)
466
+ name = handoff[:name]
467
+ data = handoff[:data]
468
+ context = handoff[:context]
469
+
470
+ with_current_state(generation: context.fetch(:generation), active: context[:active]) do
471
+ validate_lease_data!(name: name, data: data, require_credentials: context[:reconnect] || @refs.dig(name, :password))
472
+ owner = @owned_leases[handoff[:ownership]]
473
+ raise 'Lease candidate is being revoked or has already been retired' if handoff[:ownership] && (!owner || owner[:revoking])
474
+
475
+ owner[:retired] = false if owner
476
+
477
+ previous = @active_leases[name]
478
+ snapshots = setting_snapshots(name: name, data: data)
479
+ transaction = { previous: previous, data: @lease_cache[name], settings: snapshots, candidate: handoff[:candidate] }
480
+ handoff[:transaction] = transaction
481
+ @installations[handoff[:candidate]] = Thread.current
482
+ snapshots.each do |target, key, old, value, binding|
483
+ target[key] = value unless old == value
484
+ binding[:value] = value if binding
485
+ end
486
+ @lease_cache[name] = data
487
+ @active_leases[name] = handoff[:candidate]
488
+ handoff[:reconnect] = context[:reconnect] || (previous && transaction[:data] != data) ||
489
+ snapshots.any? { |_target, _key, old, value| old != value }
490
+ true
315
491
  end
316
492
  end
317
493
 
318
- def renewal_loop
319
- log.info 'LeaseManager: renewal loop started'
320
- while running?
321
- interruptible_sleep(RENEWAL_CHECK_INTERVAL)
322
- renew_approaching_leases if running?
494
+ def validate_lease_data!(name:, data:, require_credentials:, **)
495
+ raise "Vault returned invalid lease data for #{name}" unless data.is_a?(Hash)
496
+ return unless name == :rabbitmq && require_credentials
497
+
498
+ username = data[:username] || data['username']
499
+ password = data[:password] || data['password']
500
+ raise 'RabbitMQ lease requires username and password' unless username && password
501
+ end
502
+
503
+ def abort_install(handoff:, **)
504
+ name = handoff[:name]
505
+ context = handoff[:context]
506
+ generation = context.fetch(:generation)
507
+ active = context[:active]
508
+ ownership = handoff[:ownership]
509
+ restored = rollback_install(name: name, transaction: handoff[:transaction], generation: generation, active: active)
510
+ safe_to_retire = !handoff[:attempted_reconnect] || !lifecycle_current?(generation: generation, active: active)
511
+ if handoff[:attempted_reconnect] && restored
512
+ begin
513
+ safe_to_retire = Legion::Crypt::VaultOperation.with_timeout(deadline: context[:deadline]) do
514
+ trigger_reconnect(name, generation: generation, active: active)
515
+ end
516
+ rescue StandardError => e
517
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.rollback_reconnect', lease_name: name)
518
+ end
519
+ end
520
+ @state_mutex.synchronize do
521
+ entry = @owned_leases[ownership]
522
+ entry[:retired] = true if entry && safe_to_retire && @active_leases[name]&.dig(:ownership) != ownership
323
523
  end
324
- log.info 'LeaseManager: renewal loop exiting'
325
- rescue StandardError => e
326
- handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop')
327
- log.error("LeaseManager: renewal loop error: #{e.message} — restarting")
328
- retry if running?
329
524
  end
330
525
 
331
- def renew_approaching_leases
332
- leases = @state_mutex.synchronize { @active_leases.keys }
333
- leases.each do |name|
334
- lease = @state_mutex.synchronize { @active_leases[name]&.dup }
335
- next unless lease
336
- next unless approaching_expiry?(lease)
337
-
338
- remaining = lease[:expires_at] ? (lease[:expires_at] - Time.now).round(1) : 'unknown'
339
- log.debug("LeaseManager: lease '#{name}' approaching expiry " \
340
- "(remaining=#{remaining}s renewable=#{lease[:renewable]} has_path=#{!lease[:path].nil?})")
341
-
342
- if lease[:renewable]
343
- renew_lease(name, lease)
344
- elsif lease[:path]
345
- log.info("LeaseManager: lease '#{name}' is non-renewable — re-issuing from #{lease[:path]}")
346
- reissue_lease(name)
347
- else
348
- log.warn("LeaseManager: lease '#{name}' is non-renewable and has no path for reissue — " \
349
- "will expire at #{lease[:expires_at]}")
526
+ def setting_snapshots(name:, data:, **)
527
+ (@refs[name] || {}).flat_map do |key, paths|
528
+ raise "Lease #{name} omitted referenced key #{key}" unless data.key?(key) || data.key?(key.to_s)
529
+
530
+ value = data.key?(key) ? data[key] : data[key.to_s]
531
+ paths.filter_map do |path|
532
+ record = @ref_bindings[path]
533
+ target, segment = setting_target(path: path, settings: record&.dig(:settings))
534
+ raise "Lease #{name} has an unwritable settings destination #{path.inspect}" unless target
535
+
536
+ binding = record&.dig(:binding)
537
+ raise "Lease #{name} has an immutable settings binding" if binding&.frozen?
538
+ next if target[segment] == value && (!binding || binding[:value] == value)
539
+
540
+ [target, segment, target[segment], value, binding, binding&.dig(:value)]
350
541
  end
351
542
  end
352
543
  end
353
544
 
354
- def renew_lease(name, lease)
355
- lease_id = lease[:lease_id].to_s
356
- if lease_id.empty?
357
- log.warn("LeaseManager: lease '#{name}' is renewable but has no lease_id")
358
- if lease[:path]
359
- log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}")
360
- reissue_lease(name)
545
+ def rollback_install(name:, transaction:, generation:, active:, **)
546
+ return false unless transaction
547
+
548
+ with_current_state(generation: generation, active: active) do
549
+ current = @active_leases[name]
550
+ next false unless current.equal?(transaction[:candidate]) || current.equal?(transaction[:previous])
551
+
552
+ transaction[:settings].each do |snapshot|
553
+ target, key, previous, candidate, binding, previous_binding = snapshot
554
+ target[key] = previous if target[key] == candidate
555
+ binding[:value] = previous_binding if binding && binding[:value] == candidate
556
+ end
557
+ if transaction[:previous]
558
+ @active_leases[name] = transaction[:previous]
559
+ @lease_cache[name] = transaction[:data]
361
560
  else
362
- log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \
363
- "lease will expire at #{lease[:expires_at]}")
561
+ @active_leases.delete(name)
562
+ @lease_cache.delete(name)
364
563
  end
365
- return
564
+ true
366
565
  end
566
+ rescue StandardError => e
567
+ handle_exception(e, level: :error, operation: 'crypt.lease_manager.rollback_install', lease_name: name)
568
+ false
569
+ end
367
570
 
368
- log.info("LeaseManager: renewing lease '#{name}' (lease_id=#{lease_id[0..11]}...)")
369
- response = sys.renew(lease_id)
370
- new_ttl = response.respond_to?(:lease_duration) ? response.lease_duration : nil
371
- @state_mutex.synchronize do
372
- current_lease = @active_leases[name]
373
- next unless current_lease
571
+ def schedule_retry(name:, path:, client:, generation:, **options)
572
+ with_current_state(generation: generation, active: options[:active]) do
573
+ previous = @pending[name]
574
+ next false if options[:pending] && !previous.equal?(options[:pending])
575
+ next false if options[:lease] && @active_leases.dig(name, :ownership) != options[:lease][:ownership]
374
576
 
375
- if new_ttl
376
- current_lease[:lease_duration] = new_ttl
377
- current_lease[:expires_at] = Time.now + new_ttl
378
- end
379
- current_lease[:renewable] = response.renewable? if response.respond_to?(:renewable?)
577
+ attempts = previous && previous[:path] == path && previous[:client].equal?(client) ? previous[:attempts] : 0
578
+ attempts = [attempts + 1, 7].min
579
+ @pending[name] = { path: path, client: client, operation: options.fetch(:operation, :acquire), attempts: attempts,
580
+ next_attempt_at: monotonic_now + [2**(attempts - 1), MAX_RETRY_DELAY].min }
581
+ @wake.broadcast
380
582
  end
381
- if new_ttl
382
- log.info("LeaseManager: renewed lease '#{name}' (new_ttl=#{new_ttl}s)")
383
- else
384
- log.warn("LeaseManager: renewed lease '#{name}' but Vault returned no lease_duration — keeping previous TTL")
583
+ end
584
+
585
+ def retire_unused_leases(name: nil, deadline: nil, force: false, **)
586
+ keys = @state_mutex.synchronize do
587
+ @owned_leases.filter_map do |key, entry|
588
+ key if entry[:retired] && (!name || entry[:name] == name) && (force || entry[:next_attempt_at] <= monotonic_now)
589
+ end
385
590
  end
591
+ keys.each { |key| revoke_owned_lease(key: key, deadline: deadline) }
592
+ end
593
+
594
+ def revoke_owned_lease(key:, deadline:, **)
595
+ entry = @state_mutex.synchronize do
596
+ item = @owned_leases[key]
597
+ next unless item && item[:retired] && !item[:revoking]
598
+ next if @active_leases.any? { |_name, lease| lease[:ownership] == key }
386
599
 
387
- cached_data = @state_mutex.synchronize { @lease_cache[name] }
388
- if response.data && response.data != cached_data
389
- @state_mutex.synchronize { @lease_cache[name] = response.data }
390
- push_to_settings(name)
391
- log.info("LeaseManager: lease '#{name}' credentials changed during renewal — settings updated")
600
+ item[:revoking] = true
601
+ item
392
602
  end
603
+ return unless entry
604
+
605
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) { entry[:client].sys.revoke(lease_id: entry[:lease_id]) }
606
+ @state_mutex.synchronize { @owned_leases.delete(key) if @owned_leases[key].equal?(entry) }
393
607
  rescue StandardError => e
394
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
395
- log.warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
396
- if lease[:path]
397
- log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}")
398
- reissue_lease(name)
399
- else
400
- log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \
401
- "lease will expire at #{lease[:expires_at]}")
608
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.revoke_owned_lease', lease_name: entry&.dig(:name))
609
+ @state_mutex.synchronize do
610
+ if entry && @owned_leases[key].equal?(entry)
611
+ entry[:attempts] = [entry[:attempts] + 1, 7].min
612
+ entry[:next_attempt_at] = monotonic_now + [2**(entry[:attempts] - 1), MAX_RETRY_DELAY].min
613
+ end
402
614
  end
615
+ ensure
616
+ @state_mutex.synchronize { entry[:revoking] = false if entry }
403
617
  end
404
618
 
405
- def lease_valid?(name)
406
- meta = @state_mutex.synchronize { @active_leases[name]&.dup }
407
- return false unless meta
619
+ def stop_renewal_thread(deadline: nil)
620
+ thread = request_stop
621
+ return unless thread
408
622
 
409
- expires_at = meta[:expires_at]
410
- return false unless expires_at
623
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) { thread.join }
624
+ @state_mutex.synchronize { @renewal_thread = nil if @renewal_thread.equal?(thread) }
625
+ rescue StandardError => e
626
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.stop_renewal_thread')
627
+ end
411
628
 
412
- expires_at > Time.now
629
+ def renewal_loop(generation:)
630
+ while running?(generation: generation)
631
+ renew_approaching_leases(generation: generation)
632
+ interruptible_sleep(generation: generation)
633
+ end
634
+ rescue StandardError => e
635
+ handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop')
636
+ interruptible_sleep(RENEWAL_CHECK_INTERVAL, generation: generation)
637
+ retry if running?(generation: generation)
413
638
  end
414
639
 
415
- def revoke_expired_lease(name)
416
- meta = @state_mutex.synchronize { @active_leases[name]&.dup }
417
- return unless meta
640
+ # Called while holding @state_mutex, immediately before the condition wait.
641
+ def next_maintenance_delay
642
+ delays = @pending.values.map { |entry| entry[:next_attempt_at] - monotonic_now }
643
+ @active_leases.each do |name, lease|
644
+ delays << renewal_delay(lease: lease) unless @pending.key?(name)
645
+ end
646
+ @owned_leases.each do |key, entry|
647
+ next unless entry[:retired] && !entry[:revoking]
648
+ next if @active_leases.each_value.any? { |lease| lease[:ownership] == key }
418
649
 
419
- lease_id = meta[:lease_id]
420
- return if lease_id.nil? || lease_id.empty?
650
+ delays << (entry[:next_attempt_at] - monotonic_now)
651
+ end
652
+ (delays.min || RENEWAL_CHECK_INTERVAL).clamp(MINIMUM_SLEEP, RENEWAL_CHECK_INTERVAL)
653
+ end
421
654
 
422
- begin
423
- sys.revoke(lease_id)
424
- log.debug("LeaseManager: revoked expired lease '#{name}' (#{lease_id}) before re-fetch")
425
- rescue StandardError => e
426
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.revoke_expired_lease', lease_name: name)
427
- log.warn("LeaseManager: failed to revoke expired lease '#{name}' (#{lease_id}): #{e.message}")
428
- ensure
429
- @state_mutex.synchronize do
430
- @active_leases.delete(name)
431
- @lease_cache.delete(name)
655
+ def renew_approaching_leases(generation: @generation)
656
+ leases = @state_mutex.synchronize { @active_leases.to_a.sort_by { |_name, lease| renewal_delay(lease: lease) } }
657
+ attempted = []
658
+ leases.each do |name, lease|
659
+ break unless lifecycle_current?(generation: generation)
660
+ next if @state_mutex.synchronize { @pending.key?(name) }
661
+ next if renewal_delay(lease: lease).positive?
662
+
663
+ attempted << name
664
+ if lease_renewable?(lease: lease)
665
+ renew_lease(name, lease.dup, generation: generation)
666
+ else
667
+ acquire_lease(name: name, path: lease[:path], client: lease[:client], generation: generation, lease: lease)
432
668
  end
433
669
  end
670
+ pending = @state_mutex.synchronize { @pending.to_a }
671
+ pending.each do |name, entry|
672
+ break unless lifecycle_current?(generation: generation)
673
+ next if attempted.include?(name) || entry[:next_attempt_at] > monotonic_now
674
+
675
+ lease = @state_mutex.synchronize { @active_leases[name]&.dup }
676
+ if entry[:operation] == :renew && lease_renewable?(lease: lease)
677
+ renew_lease(name, lease, generation: generation, pending: entry)
678
+ else
679
+ acquire_lease(name: name, path: entry[:path], client: entry[:client], generation: generation, pending: entry, lease: lease)
680
+ end
681
+ end
682
+ retire_unused_leases if lifecycle_current?(generation: generation)
434
683
  end
435
684
 
436
- def approaching_expiry?(lease)
437
- expires_at = lease[:expires_at]
438
- lease_duration = lease[:lease_duration]
685
+ def renew_lease(name, lease, generation: @generation, pending: nil)
686
+ name = name.to_sym
687
+ operation_deadline = nil
688
+ with_lease_operation(name: name) do |deadline|
689
+ operation_deadline = deadline
690
+ return false unless lifecycle_current?(generation: generation)
691
+ return false unless lease_operation_current?(name: name, pending: pending, lease: lease)
692
+
693
+ raise 'Lease cannot be renewed without an ID' if lease[:lease_id].to_s.empty?
439
694
 
440
- return true if expires_at.nil? || lease_duration.nil?
695
+ response = lease[:client].sys.renew(lease_id: lease[:lease_id])
696
+ raise 'Lease renewal did not return a positive TTL' unless response&.lease_duration&.positive?
441
697
 
442
- remaining = expires_at - Time.now
443
- remaining < (lease_duration * 0.5)
698
+ data = response.data
699
+ data = @state_mutex.synchronize { @lease_cache[name] } if data.nil? || data.empty?
700
+ lease_id = response.lease_id.to_s.empty? ? lease[:lease_id] : response.lease_id
701
+ ownership = if lease_id == lease[:lease_id]
702
+ lease[:ownership]
703
+ else
704
+ track_ownership(name: name, client: lease[:client], lease_id: lease_id)
705
+ end
706
+ install_lease(name: name, path: lease[:path], response: response, client: lease[:client], generation: generation,
707
+ deadline: deadline, data: data, lease_id: lease_id, ownership: ownership)
708
+ end
709
+ rescue StandardError => e
710
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
711
+ return reissue_lease(name, generation: generation, deadline: operation_deadline) if operation_deadline && !e.is_a?(Timeout::Error) && lease[:path]
712
+
713
+ schedule_retry(name: name, path: lease[:path], client: lease[:client], generation: generation, operation: :renew, pending: pending)
714
+ false
444
715
  end
445
716
 
446
- def write_setting(path, value)
447
- return if path.nil? || path.empty?
717
+ def lease_valid?(name, path:, client:)
718
+ lease = @state_mutex.synchronize { @active_leases[name.to_sym] }
719
+ lease && lease[:path] == path && lease[:client].equal?(client) && lease_unexpired?(lease: lease)
720
+ end
721
+
722
+ # Called while holding @state_mutex; expired credentials are never exposed to readers.
723
+ def cached_lease_data(name:, **)
724
+ lease = @active_leases[name]
725
+ return @lease_cache[name] if lease && lease_unexpired?(lease: lease)
726
+
727
+ @lease_cache.delete(name)
728
+ nil
729
+ end
448
730
 
449
- target = path[1..-2].reduce(Legion::Settings[path[0]]) do |node, segment|
450
- break nil unless node.is_a?(Hash)
731
+ def lease_unexpired?(lease:, **)
732
+ lease[:expires_at].nil? || lease[:expires_at] > Time.now
733
+ end
451
734
 
452
- node[segment]
735
+ def lease_renewable?(lease:, **)
736
+ lease && lease[:renewable] && lease_unexpired?(lease: lease)
737
+ end
738
+
739
+ def renewal_delay(lease:, **)
740
+ ttl = lease[:lease_duration] || 0
741
+ return lease[:fetched_at] + RENEWAL_CHECK_INTERVAL - Time.now unless ttl.positive?
742
+ return 0 unless lease[:expires_at]
743
+
744
+ lease[:expires_at] - Time.now - (ttl * 0.5)
745
+ end
746
+
747
+ def setting_target(path:, settings: nil, **)
748
+ return unless path.is_a?(Array) && !path.empty?
749
+
750
+ target = settings
751
+ target ||= Legion::Settings.loader&.settings if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader)
752
+ path.each_with_index do |segment, index|
753
+ return nil unless valid_setting_key?(container: target, key: segment)
754
+ return target.frozen? ? nil : [target, segment] if index == path.length - 1
755
+
756
+ target = target[segment]
453
757
  end
454
- target[path.last] = value if target.is_a?(Hash)
758
+ end
759
+
760
+ def valid_setting_key?(container:, key:)
761
+ case container
762
+ when Hash then key.is_a?(Symbol) && container.key?(key)
763
+ when Array then key.is_a?(Integer) && key >= 0 && key < container.length
764
+ end
765
+ end
766
+
767
+ def write_setting(path, value)
768
+ record = @ref_bindings[path]
769
+ target, key = setting_target(path: path, settings: record&.dig(:settings))
770
+ binding = record&.dig(:binding)
771
+ return false unless target && !binding&.frozen?
772
+
773
+ target[key] = value
774
+ binding[:value] = value if binding
775
+ true
455
776
  rescue StandardError => e
456
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.write_setting', path: path.join('.'))
457
- log.warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}")
777
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.write_setting', path: path.inspect)
778
+ false
458
779
  end
459
780
 
460
- def trigger_reconnect(name)
461
- name = name.to_sym if name.respond_to?(:to_sym)
462
- case name
781
+ def trigger_reconnect(name, generation: @generation, active: nil)
782
+ return false unless lifecycle_current?(generation: generation, active: active)
783
+
784
+ case name.to_sym
463
785
  when :rabbitmq
464
- return unless defined?(Legion::Transport::Connection)
786
+ return true unless defined?(Legion::Transport::Connection)
465
787
 
466
- Legion::Transport::Connection.force_reconnect
467
- log.info("LeaseManager: triggered transport reconnect after '#{name}' reissue")
788
+ connection = Legion::Transport::Connection
789
+ connection.force_reconnect == true && connection.session_open?
468
790
  when :postgresql
469
- trigger_postgresql_reconnect(name)
470
- when :redis
471
- return unless defined?(Legion::Cache)
791
+ return true unless defined?(Legion::Data::Connection)
472
792
 
473
- if Legion::Cache.respond_to?(:restart)
474
- Legion::Cache.restart
475
- elsif Legion::Cache.respond_to?(:reconnect)
476
- Legion::Cache.reconnect
477
- end
478
- log.info("LeaseManager: triggered cache reconnect after '#{name}' reissue")
793
+ connection = Legion::Data::Connection
794
+ connection.respond_to?(:reconnect_with_fresh_creds) && connection.reconnect_with_fresh_creds == true
795
+ when :redis
796
+ return true unless defined?(Legion::Cache)
797
+
798
+ cache = Legion::Cache
799
+ result = if cache.respond_to?(:restart)
800
+ cache.restart
801
+ elsif cache.respond_to?(:reconnect)
802
+ cache.reconnect
803
+ end
804
+ result == true && (!cache.respond_to?(:connected?) || cache.connected?)
805
+ else
806
+ true
479
807
  end
480
808
  rescue StandardError => e
481
809
  handle_exception(e, level: :error, operation: 'crypt.lease_manager.trigger_reconnect', lease_name: name)
482
- log.error("LeaseManager: reconnect for '#{name}' FAILED: #{e.message} — " \
483
- 'services may be unavailable until the next lease rotation')
810
+ false
484
811
  end
485
812
 
486
- def trigger_postgresql_reconnect(name)
487
- unless defined?(Legion::Data::Connection)
488
- log.debug("LeaseManager: no Legion::Data::Connection loaded for '#{name}' reconnect")
489
- return
490
- end
813
+ def with_current_state(generation:, active: nil, **)
814
+ @state_mutex.synchronize do
815
+ next false unless lifecycle_current?(generation: generation)
491
816
 
492
- if Legion::Data::Connection.respond_to?(:reconnect_with_fresh_creds)
493
- success = Legion::Data::Connection.reconnect_with_fresh_creds
494
- if success
495
- log.info("LeaseManager: reconnected data layer with fresh credentials after '#{name}' reissue")
496
- else
497
- log.error("LeaseManager: FAILED to reconnect data layer after '#{name}' reissue — " \
498
- 'Apollo and other DB-backed services may be unavailable')
499
- end
500
- elsif Legion::Data::Connection.respond_to?(:sequel) && Legion::Data::Connection.sequel
501
- log.warn('LeaseManager: legion-data does not support reconnect_with_fresh_creds — ' \
502
- 'falling back to pool disconnect (may use stale credentials)')
503
- Legion::Data::Connection.sequel.disconnect
504
- Legion::Data::Connection.sequel.test_connection
505
- log.info("LeaseManager: triggered data pool reconnect after '#{name}' reissue (legacy path)")
506
- else
507
- log.warn("LeaseManager: no active data connection to reconnect after '#{name}' reissue")
817
+ next yield unless active
818
+
819
+ result = false
820
+ active.call { result = yield }
821
+ result
508
822
  end
509
823
  end
510
824
 
511
- def running?
512
- @state_mutex.synchronize { @running }
825
+ def lifecycle_current?(generation:, active: nil, **)
826
+ !@stopped && generation == @generation &&
827
+ !(Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping) && (!active || active.call)
513
828
  end
514
829
 
515
- def interruptible_sleep(seconds)
516
- deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
517
- loop do
518
- remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
519
- break if remaining <= 0 || !running?
830
+ def running?(generation: @generation)
831
+ @state_mutex.synchronize { @running && lifecycle_current?(generation: generation) }
832
+ end
520
833
 
521
- sleep([remaining, 1.0].min)
834
+ def interruptible_sleep(seconds = nil, generation: @generation)
835
+ @state_mutex.synchronize do
836
+ @wake.wait(@state_mutex, seconds || next_maintenance_delay) if @running && lifecycle_current?(generation: generation)
522
837
  end
523
838
  end
524
839
  end