legion-crypt 1.5.12 → 1.5.37

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,160 @@ 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
+ @lease_locks = {}
25
+ @state_mutex = Mutex.new
26
+ @wake = ConditionVariable.new
19
27
  @running = false
20
28
  @renewal_thread = nil
21
- @state_mutex = Mutex.new
29
+ @generation = 0
30
+ @stopped = false
31
+ @renewal_generation = nil
22
32
  end
23
33
 
24
34
  def start(definitions, vault_client: nil)
25
- @state_mutex.synchronize { @vault_client = vault_client }
26
- return if definitions.nil? || definitions.empty?
35
+ client = vault_client || Legion::Crypt.vault_client
36
+ generation = @state_mutex.synchronize do
37
+ return if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
27
38
 
39
+ @stopped = false
40
+ @vault_client = client
41
+ @generation
42
+ end
28
43
  register_at_exit_hook
44
+ (definitions || {}).each do |name, opts|
45
+ break unless lifecycle_current?(generation: generation)
29
46
 
30
- log.info "LeaseManager start requested definitions=#{definitions.size}"
31
- definitions.each do |name, opts|
47
+ name = name.to_sym
32
48
  path = opts['path'] || opts[:path]
33
49
  next unless path
34
50
 
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)
51
+ pending = @state_mutex.synchronize { @pending[name] }
52
+ next if lease_valid?(name, path: path, client: client)
53
+ next if pending && pending[:path] == path && pending[:client].equal?(client) && pending[:next_attempt_at] > monotonic_now
41
54
 
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
49
-
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
55
+ acquire_lease(name: name, path: path, client: client, generation: generation)
58
56
  end
59
57
  end
60
58
 
61
59
  def fetched_count
62
- @state_mutex.synchronize { @active_leases.size }
60
+ @state_mutex.synchronize { @active_leases.count { |name, _lease| cached_lease_data(name: name) } }
63
61
  end
64
62
 
65
63
  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
64
+ data = lease_data(name)
65
+ return unless data
70
66
 
71
- data[key.to_sym] || data[key.to_s]
67
+ data.key?(key.to_sym) ? data[key.to_sym] : data[key.to_s]
72
68
  end
73
69
 
74
70
  def lease_data(name)
75
- @state_mutex.synchronize { @lease_cache[name] }
71
+ @state_mutex.synchronize { cached_lease_data(name: name.to_sym) }
76
72
  end
77
73
 
78
74
  attr_reader :active_leases
79
75
 
80
- def register_ref(name, key, path)
81
- @state_mutex.synchronize do
82
- @refs[name] ||= {}
83
- @refs[name][key] = path
76
+ def register_ref(name, key, path, generation: @generation)
77
+ return false unless path.is_a?(Array) && !path.empty?
78
+
79
+ with_current_state(generation: generation) do
80
+ @refs[name.to_sym] ||= {}
81
+ destinations = (@refs[name.to_sym][key.to_sym] ||= [])
82
+ destinations << path.dup.freeze unless destinations.include?(path)
84
83
  end
85
84
  end
86
85
 
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]
92
- end
93
- return if refs.nil? || refs.empty?
94
- return unless data
86
+ def push_to_settings(name, generation: @generation, active: nil)
87
+ count = with_current_state(generation: generation, active: active) do
88
+ refs = @refs[name.to_sym]
89
+ data = cached_lease_data(name: name.to_sym)
90
+ next 0 if refs.nil? || refs.empty? || data.nil?
91
+
92
+ refs.sum do |key, paths|
93
+ next 0 unless data.key?(key) || data.key?(key.to_s)
95
94
 
96
- refs.each do |key, path|
97
- value = data[key.to_sym] || data[key.to_s]
98
- write_setting(path, value)
95
+ value = data.key?(key) ? data[key] : data[key.to_s]
96
+ paths.count { |path| write_setting(path, value) }
97
+ end
99
98
  end
99
+ return unless count
100
100
 
101
- log.info("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
101
+ log.info("Lease '#{name}' rotated — updated #{count} settings reference(s)")
102
+ count
102
103
  end
103
104
 
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
105
  def vault_logical
107
- logical
106
+ client = @state_mutex.synchronize { @vault_client } || Legion::Crypt.vault_client
107
+ client.logical
108
108
  end
109
109
 
110
110
  def vault_sys
111
- sys
111
+ client = @state_mutex.synchronize { @vault_client } || Legion::Crypt.vault_client
112
+ client.sys
112
113
  end
113
114
 
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 }
117
-
118
- lease_names.each do |name|
119
- lease = @state_mutex.synchronize { @active_leases[name]&.dup }
120
- next unless lease && lease[:path]
115
+ def reissue_all(active: nil)
116
+ names, generation = @state_mutex.synchronize { [@active_leases.keys, @generation] }
117
+ names.each do |name|
118
+ break unless lifecycle_current?(generation: generation, active: active)
121
119
 
122
- reissue_lease(name)
120
+ reissue_lease(name, generation: generation, active: active)
123
121
  end
124
- log.info('LeaseManager: reissue_all complete')
125
122
  end
126
123
 
127
124
  def register_dynamic_lease(name:, path:, response:, settings_refs:)
125
+ name = name.to_sym
126
+ generation, client = @state_mutex.synchronize { [@generation, @vault_client] }
127
+ client ||= Legion::Crypt.vault_client
128
128
  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
- }
129
+ ownership = track_ownership(name: name, client: client, lease_id: response.lease_id)
130
+ install_started = false
131
+ with_lease_operation(name: name) do |deadline|
132
+ settings_refs.each { |ref| register_ref(name, ref[:key], ref[:path], generation: generation) }
133
+ install_started = true
134
+ install_lease(name: name, path: path, response: response, client: client, generation: generation,
135
+ deadline: deadline, reconnect: true, ownership: ownership)
140
136
  end
141
- settings_refs.each do |ref|
142
- register_ref(name, ref[:key], ref[:path])
137
+ rescue StandardError => e
138
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.register_dynamic_lease', lease_name: name)
139
+ unless install_started
140
+ @state_mutex.synchronize do
141
+ entry = @owned_leases[ownership]
142
+ entry[:retired] = true if entry && @active_leases[name]&.dig(:ownership) != ownership
143
+ end
143
144
  end
144
- log.info("LeaseManager: registered dynamic lease '#{name}' (path: #{path})")
145
+ schedule_retry(name: name, path: path, client: client, generation: generation)
146
+ false
145
147
  end
146
148
 
147
- def reissue_lease(name)
149
+ def reissue_lease(name, generation: @generation, active: nil, deadline: nil)
150
+ name = name.to_sym
148
151
  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
152
+ return false unless lease && lease[:path] && lifecycle_current?(generation: generation, active: active)
179
153
 
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}")
154
+ acquire_lease(name: name, path: lease[:path], client: lease[:client], generation: generation,
155
+ active: active, deadline: deadline, lease: lease)
188
156
  end
189
157
 
190
158
  def start_renewal_thread
159
+ previous = @state_mutex.synchronize { @renewal_thread if @renewal_generation != @generation }
160
+ Legion::Crypt::VaultOperation.with_timeout { previous.join } if previous&.alive?
191
161
  @state_mutex.synchronize do
192
- return if @renewal_thread&.alive?
162
+ return if @renewal_thread&.alive? || @stopped
163
+ return if Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
193
164
 
194
165
  @running = true
195
- @renewal_thread = Thread.new { renewal_loop }
166
+ generation = @generation
167
+ @renewal_generation = generation
168
+ @renewal_thread = Thread.new { renewal_loop(generation: generation) }
196
169
  end
197
170
  log.info 'LeaseManager renewal thread started'
198
171
  end
@@ -201,41 +174,43 @@ module Legion
201
174
  @state_mutex.synchronize { @renewal_thread&.alive? || false }
202
175
  end
203
176
 
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
177
+ def request_stop
178
+ @state_mutex.synchronize do
179
+ @stopped = true
180
+ @running = false
181
+ @generation += 1
182
+ @lease_locks.clear
183
+ @wake.broadcast
184
+ @renewal_thread
220
185
  end
186
+ end
221
187
 
188
+ def shutdown(deadline: nil)
189
+ deadline ||= monotonic_now + Legion::Crypt::VaultOperation::SHUTDOWN_TIMEOUT
190
+ request_stop
222
191
  @state_mutex.synchronize do
223
- @lease_cache.clear
224
192
  @active_leases.clear
193
+ @lease_cache.clear
225
194
  @refs.clear
195
+ @pending.clear
226
196
  @vault_client = nil
197
+ @owned_leases.each_value { |entry| entry[:retired] = true }
227
198
  end
199
+ stop_renewal_thread(deadline: deadline)
200
+ retire_unused_leases(deadline: deadline, force: true)
228
201
  log.info 'LeaseManager shutdown complete'
229
202
  end
230
203
 
231
204
  def reset!
205
+ stop_renewal_thread
232
206
  @state_mutex.synchronize do
233
- @running = false
234
207
  @lease_cache.clear
235
208
  @active_leases.clear
209
+ @owned_leases.clear
210
+ @pending.clear
236
211
  @refs.clear
237
212
  @vault_client = nil
238
- @renewal_thread = nil
213
+ @stopped = false
239
214
  end
240
215
  end
241
216
 
@@ -245,280 +220,455 @@ module Legion
245
220
  return if @at_exit_registered
246
221
 
247
222
  at_exit do
248
- next if @state_mutex.synchronize { @active_leases.empty? }
223
+ next if @state_mutex.synchronize { @owned_leases.empty? }
249
224
 
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}")
225
+ shutdown
226
+ rescue StandardError => e
227
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.at_exit')
255
228
  end
256
229
  @at_exit_registered = true
257
230
  end
258
231
 
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
- }
232
+ def monotonic_now
233
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
234
+ end
235
+
236
+ def with_lease_operation(name:, deadline: nil, **)
237
+ lock = @state_mutex.synchronize { @lease_locks[name] ||= Mutex.new }
238
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) do |operation_deadline|
239
+ lock.synchronize { yield operation_deadline }
270
240
  end
271
241
  end
272
242
 
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)
243
+ def lease_operation_current?(name:, pending: nil, lease: nil, **)
244
+ @state_mutex.synchronize do
245
+ next false if pending && !@pending[name].equal?(pending)
280
246
 
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'}")
247
+ lease.nil? || @active_leases.dig(name, :ownership) == lease[:ownership]
248
+ end
285
249
  end
286
250
 
287
- def logical
288
- client = @state_mutex.synchronize { @vault_client }
289
- client ? client.logical : ::Vault.logical
251
+ def acquire_lease(name:, path:, client:, generation:, **options)
252
+ active = options[:active]
253
+ deadline = options[:deadline]
254
+ with_lease_operation(name: name, deadline: deadline) do |operation_deadline|
255
+ return false unless lifecycle_current?(generation: generation, active: active)
256
+ return false unless lease_operation_current?(name: name, pending: options[:pending], lease: options[:lease])
257
+ raise ArgumentError, 'Lease acquisition requires a path' unless path
258
+
259
+ response = client.logical.read(path: path)
260
+ raise "Vault returned no lease for #{name}" unless response
261
+
262
+ install_lease(name: name, path: path, response: response, client: client, generation: generation,
263
+ active: active, deadline: operation_deadline)
264
+ end
265
+ rescue StandardError => e
266
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.acquire_lease', lease_name: name, path: path)
267
+ schedule_retry(name: name, path: path, client: client, generation: generation, active: active,
268
+ pending: options[:pending], lease: options[:lease])
269
+ false
290
270
  end
291
271
 
292
- def sys
293
- client = @state_mutex.synchronize { @vault_client }
294
- client ? client.sys : ::Vault.sys
272
+ def track_ownership(name:, client:, lease_id:, **)
273
+ return if lease_id.nil? || lease_id.empty?
274
+
275
+ key = [client, lease_id]
276
+ @state_mutex.synchronize do
277
+ @owned_leases[key] ||= { name: name, client: client, lease_id: lease_id, retired: false,
278
+ revoking: false, attempts: 0, next_attempt_at: 0 }
279
+ end
280
+ key
281
+ end
282
+
283
+ def install_lease(name:, path:, response:, client:, **context)
284
+ lease_id = context.fetch(:lease_id) { response.lease_id }
285
+ ownership = context.fetch(:ownership) { track_ownership(name: name, client: client, lease_id: lease_id) }
286
+ handoff = { name: name, ownership: ownership, context: context }
287
+ now = Time.now
288
+ ttl = response.lease_duration || 0
289
+ candidate = { lease_id: lease_id, lease_duration: ttl, renewable: response.renewable?,
290
+ expires_at: ttl.positive? ? now + ttl : nil, fetched_at: now, path: path,
291
+ client: client, ownership: ownership }
292
+ handoff[:candidate] = candidate
293
+ handoff[:data] = context.fetch(:data) { response.data }
294
+ return false unless stage_install(handoff: handoff)
295
+
296
+ generation = context.fetch(:generation)
297
+ active = context[:active]
298
+ handoff[:attempted_reconnect] = handoff[:reconnect]
299
+ raise "Lease consumer reconnect failed for #{name}" if handoff[:reconnect] && !trigger_reconnect(name, generation: generation, active: active)
300
+
301
+ committed = with_current_state(generation: generation, active: active) do
302
+ next false unless @active_leases[name].equal?(candidate)
303
+
304
+ @pending.delete(name)
305
+ @owned_leases.each do |key, entry|
306
+ entry[:retired] = true if entry[:name] == name && key != ownership
307
+ end
308
+ @wake.broadcast
309
+ true
310
+ end
311
+ committed
312
+ ensure
313
+ abort_install(handoff: handoff) if handoff && !committed
314
+ retire_unused_leases(name: name, deadline: context[:deadline])
295
315
  end
296
316
 
297
- def stop_renewal_thread
298
- thread = @state_mutex.synchronize do
299
- @running = false
300
- @renewal_thread
317
+ def stage_install(handoff:, **)
318
+ name = handoff[:name]
319
+ data = handoff[:data]
320
+ context = handoff[:context]
321
+
322
+ with_current_state(generation: context.fetch(:generation), active: context[:active]) do
323
+ validate_lease_data!(name: name, data: data, require_credentials: context[:reconnect] || @refs.dig(name, :password))
324
+ owner = @owned_leases[handoff[:ownership]]
325
+ raise 'Lease candidate is being revoked or has already been retired' if handoff[:ownership] && (!owner || owner[:revoking])
326
+
327
+ owner[:retired] = false if owner
328
+
329
+ previous = @active_leases[name]
330
+ snapshots = setting_snapshots(name: name, data: data)
331
+ transaction = { previous: previous, data: @lease_cache[name], settings: snapshots, candidate: handoff[:candidate] }
332
+ handoff[:transaction] = transaction
333
+ snapshots.each { |target, key, _old, value| target[key] = value }
334
+ @lease_cache[name] = data
335
+ @active_leases[name] = handoff[:candidate]
336
+ handoff[:reconnect] = context[:reconnect] || (previous && transaction[:data] != data) || !snapshots.empty?
337
+ true
301
338
  end
302
- return unless thread
339
+ end
340
+
341
+ def validate_lease_data!(name:, data:, require_credentials:, **)
342
+ raise "Vault returned invalid lease data for #{name}" unless data.is_a?(Hash)
343
+ return unless name == :rabbitmq && require_credentials
344
+
345
+ username = data[:username] || data['username']
346
+ password = data[:password] || data['password']
347
+ raise 'RabbitMQ lease requires username and password' unless username && password
348
+ end
303
349
 
304
- begin
305
- thread.wakeup if thread.alive?
306
- rescue ThreadError => e
307
- handle_exception(e, level: :debug, operation: 'crypt.lease_manager.stop_renewal_thread')
350
+ def abort_install(handoff:, **)
351
+ name = handoff[:name]
352
+ context = handoff[:context]
353
+ generation = context.fetch(:generation)
354
+ active = context[:active]
355
+ ownership = handoff[:ownership]
356
+ restored = rollback_install(name: name, transaction: handoff[:transaction], generation: generation, active: active)
357
+ safe_to_retire = !handoff[:attempted_reconnect] || !lifecycle_current?(generation: generation, active: active)
358
+ if handoff[:attempted_reconnect] && restored
359
+ begin
360
+ safe_to_retire = Legion::Crypt::VaultOperation.with_timeout(deadline: context[:deadline]) do
361
+ trigger_reconnect(name, generation: generation, active: active)
362
+ end
363
+ rescue StandardError => e
364
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.rollback_reconnect', lease_name: name)
365
+ end
308
366
  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'
367
+ @state_mutex.synchronize do
368
+ entry = @owned_leases[ownership]
369
+ entry[:retired] = true if entry && safe_to_retire && @active_leases[name]&.dig(:ownership) != ownership
315
370
  end
316
371
  end
317
372
 
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?
373
+ def setting_snapshots(name:, data:, **)
374
+ (@refs[name] || {}).flat_map do |key, paths|
375
+ raise "Lease #{name} omitted referenced key #{key}" unless data.key?(key) || data.key?(key.to_s)
376
+
377
+ value = data.key?(key) ? data[key] : data[key.to_s]
378
+ paths.filter_map do |path|
379
+ target, segment = setting_target(path: path)
380
+ raise "Lease #{name} has an unwritable settings destination #{path.inspect}" unless target
381
+ next if target[segment] == value
382
+
383
+ [target, segment, target[segment], value]
384
+ end
323
385
  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
386
  end
330
387
 
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)
388
+ def rollback_install(name:, transaction:, generation:, active:, **)
389
+ return false unless transaction
390
+
391
+ with_current_state(generation: generation, active: active) do
392
+ current = @active_leases[name]
393
+ next false unless current.equal?(transaction[:candidate]) || current.equal?(transaction[:previous])
394
+
395
+ transaction[:settings].each do |target, key, previous, candidate|
396
+ target[key] = previous if target[key] == candidate
397
+ end
398
+ if transaction[:previous]
399
+ @active_leases[name] = transaction[:previous]
400
+ @lease_cache[name] = transaction[:data]
347
401
  else
348
- log.warn("LeaseManager: lease '#{name}' is non-renewable and has no path for reissue — " \
349
- "will expire at #{lease[:expires_at]}")
402
+ @active_leases.delete(name)
403
+ @lease_cache.delete(name)
350
404
  end
405
+ true
351
406
  end
407
+ rescue StandardError => e
408
+ handle_exception(e, level: :error, operation: 'crypt.lease_manager.rollback_install', lease_name: name)
409
+ false
352
410
  end
353
411
 
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)
361
- else
362
- log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \
363
- "lease will expire at #{lease[:expires_at]}")
412
+ def schedule_retry(name:, path:, client:, generation:, **options)
413
+ with_current_state(generation: generation, active: options[:active]) do
414
+ previous = @pending[name]
415
+ next false if options[:pending] && !previous.equal?(options[:pending])
416
+ next false if options[:lease] && @active_leases.dig(name, :ownership) != options[:lease][:ownership]
417
+
418
+ attempts = previous && previous[:path] == path && previous[:client].equal?(client) ? previous[:attempts] : 0
419
+ attempts = [attempts + 1, 7].min
420
+ @pending[name] = { path: path, client: client, operation: options.fetch(:operation, :acquire), attempts: attempts,
421
+ next_attempt_at: monotonic_now + [2**(attempts - 1), MAX_RETRY_DELAY].min }
422
+ @wake.broadcast
423
+ end
424
+ end
425
+
426
+ def retire_unused_leases(name: nil, deadline: nil, force: false, **)
427
+ keys = @state_mutex.synchronize do
428
+ @owned_leases.filter_map do |key, entry|
429
+ key if entry[:retired] && (!name || entry[:name] == name) && (force || entry[:next_attempt_at] <= monotonic_now)
364
430
  end
365
- return
366
431
  end
432
+ keys.each { |key| revoke_owned_lease(key: key, deadline: deadline) }
433
+ end
367
434
 
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
435
+ def revoke_owned_lease(key:, deadline:, **)
436
+ entry = @state_mutex.synchronize do
437
+ item = @owned_leases[key]
438
+ next unless item && item[:retired] && !item[:revoking]
439
+ next if @active_leases.any? { |_name, lease| lease[:ownership] == key }
374
440
 
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?)
441
+ item[:revoking] = true
442
+ item
380
443
  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")
444
+ return unless entry
445
+
446
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) { entry[:client].sys.revoke(lease_id: entry[:lease_id]) }
447
+ @state_mutex.synchronize { @owned_leases.delete(key) if @owned_leases[key].equal?(entry) }
448
+ rescue StandardError => e
449
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.revoke_owned_lease', lease_name: entry&.dig(:name))
450
+ @state_mutex.synchronize do
451
+ if entry && @owned_leases[key].equal?(entry)
452
+ entry[:attempts] = [entry[:attempts] + 1, 7].min
453
+ entry[:next_attempt_at] = monotonic_now + [2**(entry[:attempts] - 1), MAX_RETRY_DELAY].min
454
+ end
385
455
  end
456
+ ensure
457
+ @state_mutex.synchronize { entry[:revoking] = false if entry }
458
+ end
459
+
460
+ def stop_renewal_thread(deadline: nil)
461
+ thread = request_stop
462
+ return unless thread
386
463
 
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")
464
+ Legion::Crypt::VaultOperation.with_timeout(deadline: deadline) { thread.join }
465
+ @state_mutex.synchronize { @renewal_thread = nil if @renewal_thread.equal?(thread) }
466
+ rescue StandardError => e
467
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.stop_renewal_thread')
468
+ end
469
+
470
+ def renewal_loop(generation:)
471
+ while running?(generation: generation)
472
+ renew_approaching_leases(generation: generation)
473
+ interruptible_sleep(generation: generation)
392
474
  end
393
475
  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]}")
476
+ handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop')
477
+ interruptible_sleep(RENEWAL_CHECK_INTERVAL, generation: generation)
478
+ retry if running?(generation: generation)
479
+ end
480
+
481
+ # Called while holding @state_mutex, immediately before the condition wait.
482
+ def next_maintenance_delay
483
+ delays = @pending.values.map { |entry| entry[:next_attempt_at] - monotonic_now }
484
+ @active_leases.each do |name, lease|
485
+ delays << renewal_delay(lease: lease) unless @pending.key?(name)
486
+ end
487
+ @owned_leases.each do |key, entry|
488
+ next unless entry[:retired] && !entry[:revoking]
489
+ next if @active_leases.each_value.any? { |lease| lease[:ownership] == key }
490
+
491
+ delays << (entry[:next_attempt_at] - monotonic_now)
402
492
  end
493
+ (delays.min || RENEWAL_CHECK_INTERVAL).clamp(MINIMUM_SLEEP, RENEWAL_CHECK_INTERVAL)
403
494
  end
404
495
 
405
- def lease_valid?(name)
406
- meta = @state_mutex.synchronize { @active_leases[name]&.dup }
407
- return false unless meta
496
+ def renew_approaching_leases(generation: @generation)
497
+ leases = @state_mutex.synchronize { @active_leases.to_a.sort_by { |_name, lease| renewal_delay(lease: lease) } }
498
+ attempted = []
499
+ leases.each do |name, lease|
500
+ break unless lifecycle_current?(generation: generation)
501
+ next if @state_mutex.synchronize { @pending.key?(name) }
502
+ next if renewal_delay(lease: lease).positive?
408
503
 
409
- expires_at = meta[:expires_at]
410
- return false unless expires_at
504
+ attempted << name
505
+ if lease_renewable?(lease: lease)
506
+ renew_lease(name, lease.dup, generation: generation)
507
+ else
508
+ acquire_lease(name: name, path: lease[:path], client: lease[:client], generation: generation, lease: lease)
509
+ end
510
+ end
511
+ pending = @state_mutex.synchronize { @pending.to_a }
512
+ pending.each do |name, entry|
513
+ break unless lifecycle_current?(generation: generation)
514
+ next if attempted.include?(name) || entry[:next_attempt_at] > monotonic_now
411
515
 
412
- expires_at > Time.now
516
+ lease = @state_mutex.synchronize { @active_leases[name]&.dup }
517
+ if entry[:operation] == :renew && lease_renewable?(lease: lease)
518
+ renew_lease(name, lease, generation: generation, pending: entry)
519
+ else
520
+ acquire_lease(name: name, path: entry[:path], client: entry[:client], generation: generation, pending: entry, lease: lease)
521
+ end
522
+ end
523
+ retire_unused_leases if lifecycle_current?(generation: generation)
413
524
  end
414
525
 
415
- def revoke_expired_lease(name)
416
- meta = @state_mutex.synchronize { @active_leases[name]&.dup }
417
- return unless meta
526
+ def renew_lease(name, lease, generation: @generation, pending: nil)
527
+ name = name.to_sym
528
+ operation_deadline = nil
529
+ with_lease_operation(name: name) do |deadline|
530
+ operation_deadline = deadline
531
+ return false unless lifecycle_current?(generation: generation)
532
+ return false unless lease_operation_current?(name: name, pending: pending, lease: lease)
418
533
 
419
- lease_id = meta[:lease_id]
420
- return if lease_id.nil? || lease_id.empty?
534
+ raise 'Lease cannot be renewed without an ID' if lease[:lease_id].to_s.empty?
421
535
 
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)
432
- end
536
+ response = lease[:client].sys.renew(lease_id: lease[:lease_id])
537
+ raise 'Lease renewal did not return a positive TTL' unless response&.lease_duration&.positive?
538
+
539
+ data = response.data
540
+ data = @state_mutex.synchronize { @lease_cache[name] } if data.nil? || data.empty?
541
+ lease_id = response.lease_id.to_s.empty? ? lease[:lease_id] : response.lease_id
542
+ ownership = if lease_id == lease[:lease_id]
543
+ lease[:ownership]
544
+ else
545
+ track_ownership(name: name, client: lease[:client], lease_id: lease_id)
546
+ end
547
+ install_lease(name: name, path: lease[:path], response: response, client: lease[:client], generation: generation,
548
+ deadline: deadline, data: data, lease_id: lease_id, ownership: ownership)
433
549
  end
550
+ rescue StandardError => e
551
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name)
552
+ return reissue_lease(name, generation: generation, deadline: operation_deadline) if operation_deadline && !e.is_a?(Timeout::Error) && lease[:path]
553
+
554
+ schedule_retry(name: name, path: lease[:path], client: lease[:client], generation: generation, operation: :renew, pending: pending)
555
+ false
434
556
  end
435
557
 
436
- def approaching_expiry?(lease)
437
- expires_at = lease[:expires_at]
438
- lease_duration = lease[:lease_duration]
558
+ def lease_valid?(name, path:, client:)
559
+ lease = @state_mutex.synchronize { @active_leases[name.to_sym] }
560
+ lease && lease[:path] == path && lease[:client].equal?(client) && lease_unexpired?(lease: lease)
561
+ end
439
562
 
440
- return true if expires_at.nil? || lease_duration.nil?
563
+ # Called while holding @state_mutex; expired credentials are never exposed to readers.
564
+ def cached_lease_data(name:, **)
565
+ lease = @active_leases[name]
566
+ return @lease_cache[name] if lease && lease_unexpired?(lease: lease)
441
567
 
442
- remaining = expires_at - Time.now
443
- remaining < (lease_duration * 0.5)
568
+ @lease_cache.delete(name)
569
+ nil
444
570
  end
445
571
 
446
- def write_setting(path, value)
447
- return if path.nil? || path.empty?
572
+ def lease_unexpired?(lease:, **)
573
+ lease[:expires_at].nil? || lease[:expires_at] > Time.now
574
+ end
575
+
576
+ def lease_renewable?(lease:, **)
577
+ lease && lease[:renewable] && lease_unexpired?(lease: lease)
578
+ end
579
+
580
+ def renewal_delay(lease:, **)
581
+ ttl = lease[:lease_duration] || 0
582
+ return lease[:fetched_at] + RENEWAL_CHECK_INTERVAL - Time.now unless ttl.positive?
583
+ return 0 unless lease[:expires_at]
584
+
585
+ lease[:expires_at] - Time.now - (ttl * 0.5)
586
+ end
448
587
 
449
- target = path[1..-2].reduce(Legion::Settings[path[0]]) do |node, segment|
450
- break nil unless node.is_a?(Hash)
588
+ def setting_target(path:, **)
589
+ return unless path.is_a?(Array) && !path.empty?
451
590
 
452
- node[segment]
591
+ target = Legion::Settings.get.settings
592
+ path.each_with_index do |segment, index|
593
+ valid =
594
+ case target
595
+ when Hash then segment.is_a?(Symbol) && target.key?(segment)
596
+ when Array then segment.is_a?(Integer) && segment >= 0 && segment < target.length
597
+ end
598
+ return nil unless valid
599
+ return target.frozen? ? nil : [target, segment] if index == path.length - 1
600
+
601
+ target = target[segment]
453
602
  end
454
- target[path.last] = value if target.is_a?(Hash)
603
+ end
604
+
605
+ def write_setting(path, value)
606
+ target, key = setting_target(path: path)
607
+ return false unless target
608
+
609
+ target[key] = value
610
+ true
455
611
  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}")
612
+ handle_exception(e, level: :warn, operation: 'crypt.lease_manager.write_setting', path: path.inspect)
613
+ false
458
614
  end
459
615
 
460
- def trigger_reconnect(name)
461
- name = name.to_sym if name.respond_to?(:to_sym)
462
- case name
616
+ def trigger_reconnect(name, generation: @generation, active: nil)
617
+ return false unless lifecycle_current?(generation: generation, active: active)
618
+
619
+ case name.to_sym
463
620
  when :rabbitmq
464
- return unless defined?(Legion::Transport::Connection)
621
+ return true unless defined?(Legion::Transport::Connection)
465
622
 
466
- Legion::Transport::Connection.force_reconnect
467
- log.info("LeaseManager: triggered transport reconnect after '#{name}' reissue")
623
+ connection = Legion::Transport::Connection
624
+ connection.force_reconnect == true && connection.session_open?
468
625
  when :postgresql
469
- trigger_postgresql_reconnect(name)
470
- when :redis
471
- return unless defined?(Legion::Cache)
626
+ return true unless defined?(Legion::Data::Connection)
472
627
 
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")
628
+ connection = Legion::Data::Connection
629
+ connection.respond_to?(:reconnect_with_fresh_creds) && connection.reconnect_with_fresh_creds == true
630
+ when :redis
631
+ return true unless defined?(Legion::Cache)
632
+
633
+ cache = Legion::Cache
634
+ result = if cache.respond_to?(:restart)
635
+ cache.restart
636
+ elsif cache.respond_to?(:reconnect)
637
+ cache.reconnect
638
+ end
639
+ result == true && (!cache.respond_to?(:connected?) || cache.connected?)
640
+ else
641
+ true
479
642
  end
480
643
  rescue StandardError => e
481
644
  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')
645
+ false
484
646
  end
485
647
 
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
648
+ def with_current_state(generation:, active: nil, **)
649
+ @state_mutex.synchronize do
650
+ next false unless lifecycle_current?(generation: generation)
491
651
 
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")
652
+ next yield unless active
653
+
654
+ result = false
655
+ active.call { result = yield }
656
+ result
508
657
  end
509
658
  end
510
659
 
511
- def running?
512
- @state_mutex.synchronize { @running }
660
+ def lifecycle_current?(generation:, active: nil, **)
661
+ !@stopped && generation == @generation &&
662
+ !(Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping) && (!active || active.call)
513
663
  end
514
664
 
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?
665
+ def running?(generation: @generation)
666
+ @state_mutex.synchronize { @running && lifecycle_current?(generation: generation) }
667
+ end
520
668
 
521
- sleep([remaining, 1.0].min)
669
+ def interruptible_sleep(seconds = nil, generation: @generation)
670
+ @state_mutex.synchronize do
671
+ @wake.wait(@state_mutex, seconds || next_maintenance_delay) if @running && lifecycle_current?(generation: generation)
522
672
  end
523
673
  end
524
674
  end