activejob-temporal 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/activejob-temporal.gemspec +2 -0
- data/bin/temporal-worker +9 -21
- data/lib/activejob/temporal/configuration.rb +26 -0
- data/lib/activejob/temporal/credential_refresher.rb +211 -0
- data/lib/activejob/temporal/locales/en.yml +6 -0
- data/lib/activejob/temporal/version.rb +1 -1
- data/lib/activejob/temporal/worker_runtime.rb +1 -1
- metadata +2 -2
- data/lib/activejob/temporal/certificate_watcher.rb +0 -116
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e1e7c38c2a169353b2d9f7edadca0614e32cb6c8c33ed87fe1d29b72a7dd5cb
|
|
4
|
+
data.tar.gz: 3255fbe711eac898e2b91a65fa2bc5f858eccda59e605774fa027a6a2ba72291
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b722896482be5075f145926ad914fedd43b860d438c0badf855f030aaa501d6839233315e0427b841e24d108e89fe775375f77e8e07f8ac983f43169eac383cd
|
|
7
|
+
data.tar.gz: 6f40349c1961652d0f4c1e242a383895264cf585a2a6bcf127e3ed6dc66f9025413a664289d3ac05325ed9f10dab0d3f427c240af600555dfab8fb50d37f46fc
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `credential_poll_interval` (default 30 seconds) and `credential_file_events` (default false) configure how `tls_cert_watch` and `api_key_watch` detect rotation.
|
|
14
|
+
- `ActiveJob::Temporal::CredentialRefresher` replaces `CertificateWatcher`. Enqueue-side processes can now start it from an initializer instead of scheduling `refresh_api_key!` themselves - see [worker setup](docs/worker_setup.md#api-key-authentication).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Credential rotation is detected by comparing a content digest on an interval rather than by filesystem events. Rotation on mounts whose events never reach the process (or where the `listen` gem is absent) now works, at up to one poll interval of latency. `credential_file_events = true` restores immediate reaction by waking the check early; it never decides whether a reload happens, so a missing `listen` gem costs latency, not correctness.
|
|
18
|
+
- A cert and key pair rotating together now triggers one client rebuild instead of a debounced pair, and TLS plus API key watching share a single watcher instead of two.
|
|
19
|
+
- **Breaking:** `ActiveJob::Temporal::CertificateWatcher` is removed. Applications constructing it directly should use `CredentialRefresher.from_config`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Watching a Kubernetes projected or secret volume no longer prints listen's `directory is already being watched!` error at boot. kubelet keeps the live secret in a timestamped sibling of the `..data` symlink, so listen's recursive scan reached the same real directory twice; the versioned copy is now skipped.
|
|
23
|
+
|
|
10
24
|
## [0.3.1] - 2026-08-06
|
|
11
25
|
|
|
12
26
|
### Fixed
|
data/activejob-temporal.gemspec
CHANGED
|
@@ -39,6 +39,8 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
spec.add_dependency "globalid", ">= 0.3"
|
|
40
40
|
spec.add_dependency "temporalio", ">= 1.4.0", "< 1.7"
|
|
41
41
|
|
|
42
|
+
# listen is an optional runtime dependency, required lazily only when credential_file_events is
|
|
43
|
+
# enabled. Without it credentials still rotate, just on the poll interval instead of instantly.
|
|
42
44
|
spec.add_development_dependency "benchmark-ips", "~> 2.14"
|
|
43
45
|
spec.add_development_dependency "github_changelog_generator", "~> 1.18"
|
|
44
46
|
spec.add_development_dependency "listen", "~> 3.9"
|
data/bin/temporal-worker
CHANGED
|
@@ -260,25 +260,14 @@ reload_signal_thread = Thread.new do
|
|
|
260
260
|
end
|
|
261
261
|
Signal.trap(reload_signal) { reload_signal_queue.push(reload_signal) }
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
# Token rotation mutates the live connection (the SDK sends the header per-RPC from the
|
|
273
|
-
# connection's stored options) - no client rebuild, unlike certificate rotation above.
|
|
274
|
-
# Configuration validation guarantees api_key_file is set when api_key_watch is on.
|
|
275
|
-
api_key_watcher = nil
|
|
276
|
-
if config.api_key_watch
|
|
277
|
-
api_key_watcher = ActiveJob::Temporal::CertificateWatcher.new(
|
|
278
|
-
paths: [config.api_key_file],
|
|
279
|
-
reload_callback: -> { ActiveJob::Temporal.refresh_api_key! }
|
|
280
|
-
).start
|
|
281
|
-
end
|
|
263
|
+
# Only the TLS callback is overridden: certificate rotation has to swap the rebuilt client into
|
|
264
|
+
# the running worker, while token rotation mutates the live connection (the SDK sends the header
|
|
265
|
+
# per-RPC from the connection's stored options) and needs no worker awareness.
|
|
266
|
+
credential_refresher = ActiveJob::Temporal::CredentialRefresher.from_config(
|
|
267
|
+
config,
|
|
268
|
+
# Failures are logged by the reloader and retried on the next check, so let them propagate.
|
|
269
|
+
on_tls_change: -> { client_reloader.reload(source: "credential_refresh") }
|
|
270
|
+
).start
|
|
282
271
|
|
|
283
272
|
begin
|
|
284
273
|
worker_health.mark_started!
|
|
@@ -290,8 +279,7 @@ rescue Interrupt
|
|
|
290
279
|
# Temporal worker raises Interrupt on shutdown signals; swallow to allow clean exit.
|
|
291
280
|
ensure
|
|
292
281
|
worker_health.mark_stopped!
|
|
293
|
-
|
|
294
|
-
api_key_watcher&.stop
|
|
282
|
+
credential_refresher.stop
|
|
295
283
|
reload_signal_queue.close
|
|
296
284
|
reload_signal_thread.join(1)
|
|
297
285
|
health_check_server&.stop
|
|
@@ -159,6 +159,21 @@ module ActiveJob
|
|
|
159
159
|
"projected tokens atomically, long before they expire)"
|
|
160
160
|
},
|
|
161
161
|
|
|
162
|
+
credential_poll_interval: {
|
|
163
|
+
default: 30,
|
|
164
|
+
env_var: "ACTIVEJOB_TEMPORAL_CREDENTIAL_POLL_INTERVAL",
|
|
165
|
+
type: :integer,
|
|
166
|
+
description: "Seconds between credential file checks when tls_cert_watch or api_key_watch is enabled"
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
credential_file_events: {
|
|
170
|
+
default: false,
|
|
171
|
+
env_var: "ACTIVEJOB_TEMPORAL_CREDENTIAL_FILE_EVENTS",
|
|
172
|
+
type: :boolean,
|
|
173
|
+
description: "React to credential file changes immediately instead of waiting for the next " \
|
|
174
|
+
"poll (requires the optional listen gem; the poll interval stays the safety net)"
|
|
175
|
+
},
|
|
176
|
+
|
|
162
177
|
priority_task_queues: {
|
|
163
178
|
default: -> { {} },
|
|
164
179
|
type: :hash,
|
|
@@ -752,6 +767,7 @@ module ActiveJob
|
|
|
752
767
|
validate :validate_payload_storage_settings
|
|
753
768
|
validate :validate_tls_settings
|
|
754
769
|
validate :validate_api_key_settings
|
|
770
|
+
validate :validate_credential_refresh_settings
|
|
755
771
|
validate :validate_worker_registration_settings
|
|
756
772
|
validate :validate_local_activity_helpers
|
|
757
773
|
validate :validate_dependency_wait_settings
|
|
@@ -1092,6 +1108,16 @@ module ActiveJob
|
|
|
1092
1108
|
errors.add(:api_key_watch, :requires_path)
|
|
1093
1109
|
end
|
|
1094
1110
|
|
|
1111
|
+
def validate_credential_refresh_settings
|
|
1112
|
+
unless [true, false].include?(credential_file_events)
|
|
1113
|
+
errors.add(:credential_file_events, :not_boolean, value: credential_file_events.inspect)
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
return if credential_poll_interval.is_a?(Integer) && credential_poll_interval.positive?
|
|
1117
|
+
|
|
1118
|
+
errors.add(:credential_poll_interval, :invalid, value: credential_poll_interval.inspect)
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1095
1121
|
def validate_worker_registration_settings
|
|
1096
1122
|
unless [true, false].include?(worker_activejob_workloads)
|
|
1097
1123
|
errors.add(:worker_activejob_workloads, :not_boolean, value: worker_activejob_workloads.inspect)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../temporal"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module Temporal
|
|
8
|
+
# Keeps credential files fresh in a long-lived process: TLS material, API key files, or
|
|
9
|
+
# anything else read once at boot and rotated underneath the process later.
|
|
10
|
+
#
|
|
11
|
+
# Change detection is a content digest compared on an interval, so the only thing that can fire
|
|
12
|
+
# a callback is the bytes behind a path actually differing. Filesystem events (see :file_events)
|
|
13
|
+
# are an optional accelerator that wakes the loop early; they never decide whether a reload
|
|
14
|
+
# happens, so a missing `listen` gem or a mount without working inotify costs latency, not
|
|
15
|
+
# correctness.
|
|
16
|
+
#
|
|
17
|
+
# @example Worker: rebuild the client on cert rotation, patch the live connection on token rotation
|
|
18
|
+
# refresher = CredentialRefresher.from_config(
|
|
19
|
+
# ActiveJob::Temporal.config,
|
|
20
|
+
# on_tls_change: -> { client_reloader.reload(source: "credential_refresh") }
|
|
21
|
+
# ).start
|
|
22
|
+
#
|
|
23
|
+
# @example Enqueue-side process (web, Sidekiq) with a rotating projected token
|
|
24
|
+
# require "activejob/temporal/credential_refresher"
|
|
25
|
+
# ActiveJob::Temporal::CredentialRefresher.from_config(ActiveJob::Temporal.config).start
|
|
26
|
+
class CredentialRefresher
|
|
27
|
+
# kubelet keeps the live secret in a timestamped sibling of `..data`, so listen's recursive
|
|
28
|
+
# scan reaches the same real directory twice and prints a SymlinkLoop error to stderr.
|
|
29
|
+
# Getting this wrong costs that stderr noise and nothing else: listen only ever nudges the
|
|
30
|
+
# loop, so the digest still decides, and the interval still backs it up.
|
|
31
|
+
#
|
|
32
|
+
# @api private
|
|
33
|
+
KUBERNETES_VERSIONED_DIR = /\A\.\.\d/
|
|
34
|
+
private_constant :KUBERNETES_VERSIONED_DIR
|
|
35
|
+
|
|
36
|
+
DEFAULT_POLL_INTERVAL = 30
|
|
37
|
+
|
|
38
|
+
# Files digested as one unit. Cert and key rotate as separate writes, so grouping them turns
|
|
39
|
+
# a rotation into a single callback instead of a pair.
|
|
40
|
+
Source = Struct.new(:name, :paths, :on_change, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Builds a refresher from the `tls_cert_watch` / `api_key_watch` configuration flags.
|
|
44
|
+
# The callbacks default to the process-wide reload paths, which is what an enqueue-side
|
|
45
|
+
# process wants; workers override the TLS one so the running worker gets the new client.
|
|
46
|
+
#
|
|
47
|
+
# @param configuration [Configuration] the gem configuration
|
|
48
|
+
# @param on_tls_change [#call] invoked when any TLS file changes
|
|
49
|
+
# @param on_api_key_change [#call] invoked when the API key file changes
|
|
50
|
+
# @param logger [#log_event, #warn, #error] structured logger
|
|
51
|
+
# @param listener_factory [#to, nil] injection point for tests, defaults to `Listen`
|
|
52
|
+
# @return [CredentialRefresher] a refresher with no sources when both flags are off
|
|
53
|
+
def from_config(configuration,
|
|
54
|
+
on_tls_change: -> { ActiveJob::Temporal.reload_client! },
|
|
55
|
+
on_api_key_change: -> { ActiveJob::Temporal.refresh_api_key! },
|
|
56
|
+
logger: ActiveJob::Temporal::Logger,
|
|
57
|
+
listener_factory: nil)
|
|
58
|
+
sources = []
|
|
59
|
+
|
|
60
|
+
if configuration.tls_cert_watch
|
|
61
|
+
sources << Source.new(name: "tls", paths: tls_paths(configuration), on_change: on_tls_change)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if configuration.api_key_watch
|
|
65
|
+
sources << Source.new(name: "api_key", paths: [configuration.api_key_file], on_change: on_api_key_change)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
new(
|
|
69
|
+
sources: sources,
|
|
70
|
+
poll_interval: configuration.credential_poll_interval,
|
|
71
|
+
file_events: configuration.credential_file_events,
|
|
72
|
+
logger: logger,
|
|
73
|
+
listener_factory: listener_factory
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param configuration [Configuration] the gem configuration
|
|
78
|
+
# @return [Array<String>] configured TLS file paths, blanks removed
|
|
79
|
+
def tls_paths(configuration)
|
|
80
|
+
[
|
|
81
|
+
configuration.tls_cert_path,
|
|
82
|
+
configuration.tls_key_path,
|
|
83
|
+
configuration.tls_server_root_ca_cert_path
|
|
84
|
+
].compact.reject { |path| path.to_s.strip.empty? }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param sources [Array<Source>] credential groups to keep fresh
|
|
89
|
+
# @param poll_interval [Integer, Float] seconds between digest comparisons
|
|
90
|
+
# @param file_events [Boolean] wake the loop early on filesystem events, requires `listen`
|
|
91
|
+
# @param logger [#log_event, #warn, #error] structured logger
|
|
92
|
+
# @param listener_factory [#to, nil] injection point for tests, defaults to `Listen`
|
|
93
|
+
def initialize(sources:, poll_interval: DEFAULT_POLL_INTERVAL, file_events: false,
|
|
94
|
+
logger: ActiveJob::Temporal::Logger, listener_factory: nil)
|
|
95
|
+
@sources = sources.reject { |source| source.paths.compact.empty? }
|
|
96
|
+
@poll_interval = poll_interval
|
|
97
|
+
@file_events = file_events
|
|
98
|
+
@logger = logger
|
|
99
|
+
@listener_factory = listener_factory
|
|
100
|
+
@wakeups = Thread::Queue.new
|
|
101
|
+
@digests = {}
|
|
102
|
+
@thread = nil
|
|
103
|
+
@listener = nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Baselines each digest before polling, so a process booting on an already-rotated file does
|
|
107
|
+
# not fire a redundant reload on its first check.
|
|
108
|
+
#
|
|
109
|
+
# @return [self]
|
|
110
|
+
def start
|
|
111
|
+
return self if @sources.empty? || @thread
|
|
112
|
+
|
|
113
|
+
@sources.each { |source| @digests[source.name] = digest(source) }
|
|
114
|
+
@thread = Thread.new { run }
|
|
115
|
+
start_listener if @file_events
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [void]
|
|
120
|
+
def stop
|
|
121
|
+
@listener&.stop
|
|
122
|
+
@listener = nil
|
|
123
|
+
@wakeups.close
|
|
124
|
+
@thread&.join
|
|
125
|
+
@thread = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Re-reads every source and fires the callbacks whose content changed. Safe to call at any
|
|
129
|
+
# rate: unchanged content is a no-op.
|
|
130
|
+
#
|
|
131
|
+
# @return [void]
|
|
132
|
+
def refresh_changed_sources
|
|
133
|
+
@sources.each { |source| refresh(source) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Wakes the poll loop so the next comparison happens now instead of on the next tick.
|
|
137
|
+
# Decides nothing on its own.
|
|
138
|
+
#
|
|
139
|
+
# @return [void]
|
|
140
|
+
def nudge
|
|
141
|
+
@wakeups << :wakeup
|
|
142
|
+
rescue ClosedQueueError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def run
|
|
149
|
+
until @wakeups.closed?
|
|
150
|
+
@wakeups.pop(timeout: @poll_interval)
|
|
151
|
+
break if @wakeups.closed?
|
|
152
|
+
|
|
153
|
+
# A rotation emits a burst of events; collapse them into the single check that follows.
|
|
154
|
+
@wakeups.clear
|
|
155
|
+
refresh_changed_sources
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def refresh(source)
|
|
160
|
+
current = digest(source)
|
|
161
|
+
return if current.nil? || current == @digests[source.name]
|
|
162
|
+
|
|
163
|
+
source.on_change.call
|
|
164
|
+
# Stamped only after the callback succeeds, so a failed reload is retried on the next tick.
|
|
165
|
+
@digests[source.name] = current
|
|
166
|
+
@logger.log_event("credential_refreshed", source: source.name)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
@logger.error(
|
|
169
|
+
"credential_refresh_failed",
|
|
170
|
+
source: source.name,
|
|
171
|
+
error_class: e.class.name,
|
|
172
|
+
message: e.message
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A path can be briefly unreadable while kubelet swaps the mount. Treat that as "no reading"
|
|
177
|
+
# so the next tick retries, rather than reloading against a half-written credential.
|
|
178
|
+
def digest(source)
|
|
179
|
+
contents = source.paths.compact.map { |path| TLSFile.read(path) }
|
|
180
|
+
Digest::SHA256.hexdigest(contents.join("\0"))
|
|
181
|
+
rescue TLSFile::Error
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def start_listener
|
|
186
|
+
@listener = listener_factory.to(*watched_directories, ignore: KUBERNETES_VERSIONED_DIR) { nudge }
|
|
187
|
+
@listener.start
|
|
188
|
+
rescue LoadError => e
|
|
189
|
+
# Polling already guarantees the refresh, so a missing listen gem is a latency
|
|
190
|
+
# regression rather than a boot failure.
|
|
191
|
+
@logger.warn(
|
|
192
|
+
"credential_file_events_unavailable",
|
|
193
|
+
error_class: e.class.name,
|
|
194
|
+
message: e.message,
|
|
195
|
+
poll_interval_seconds: @poll_interval
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def watched_directories
|
|
200
|
+
@sources.flat_map(&:paths).compact.map { |path| File.dirname(File.expand_path(path)) }.uniq
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def listener_factory
|
|
204
|
+
@listener_factory ||= begin
|
|
205
|
+
require "listen"
|
|
206
|
+
Listen
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -145,6 +145,12 @@ en:
|
|
|
145
145
|
not_boolean: "must be true or false, got: %{value}"
|
|
146
146
|
requires_path: "requires api_key_file to watch"
|
|
147
147
|
|
|
148
|
+
credential_poll_interval:
|
|
149
|
+
invalid: "must be a positive number of seconds, got: %{value}"
|
|
150
|
+
|
|
151
|
+
credential_file_events:
|
|
152
|
+
not_boolean: "must be true or false, got: %{value}"
|
|
153
|
+
|
|
148
154
|
worker_activejob_workloads:
|
|
149
155
|
not_boolean: "must be true or false, got: %{value}"
|
|
150
156
|
requires_activities: "requires worker_activities when disabled - the worker would have nothing to register"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../temporal"
|
|
4
4
|
require_relative "rails_environment_loader"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "credential_refresher"
|
|
6
6
|
require_relative "reload_signal_queue"
|
|
7
7
|
require_relative "worker_client_reloader"
|
|
8
8
|
require_relative "worker_pool"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activejob-temporal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Temporal Technologies
|
|
@@ -309,7 +309,6 @@ files:
|
|
|
309
309
|
- lib/activejob/temporal/cancel.rb
|
|
310
310
|
- lib/activejob/temporal/cancel/batch_canceller.rb
|
|
311
311
|
- lib/activejob/temporal/cancel/batch_summary.rb
|
|
312
|
-
- lib/activejob/temporal/certificate_watcher.rb
|
|
313
312
|
- lib/activejob/temporal/chain_options.rb
|
|
314
313
|
- lib/activejob/temporal/child_workflow_options.rb
|
|
315
314
|
- lib/activejob/temporal/client.rb
|
|
@@ -318,6 +317,7 @@ files:
|
|
|
318
317
|
- lib/activejob/temporal/configuration.rb
|
|
319
318
|
- lib/activejob/temporal/configured_job_compatibility.rb
|
|
320
319
|
- lib/activejob/temporal/connection_worker_pool.rb
|
|
320
|
+
- lib/activejob/temporal/credential_refresher.rb
|
|
321
321
|
- lib/activejob/temporal/dead_letter_payload_validation.rb
|
|
322
322
|
- lib/activejob/temporal/dead_letter_queue.rb
|
|
323
323
|
- lib/activejob/temporal/dependency_options.rb
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ActiveJob
|
|
4
|
-
module Temporal
|
|
5
|
-
# Watches TLS certificate files and runs a reload callback when they change.
|
|
6
|
-
class CertificateWatcher
|
|
7
|
-
DEFAULT_DEBOUNCE_SECONDS = 1.0
|
|
8
|
-
|
|
9
|
-
def self.paths_from_config(configuration)
|
|
10
|
-
[
|
|
11
|
-
configuration.tls_cert_path,
|
|
12
|
-
configuration.tls_key_path,
|
|
13
|
-
configuration.tls_server_root_ca_cert_path
|
|
14
|
-
].compact.reject { |path| path.to_s.strip.empty? }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def initialize(paths:, reload_callback:, listener_factory: nil, debounce_seconds: DEFAULT_DEBOUNCE_SECONDS)
|
|
18
|
-
@paths = paths.map { |path| File.expand_path(path) }.uniq
|
|
19
|
-
@reload_callback = reload_callback
|
|
20
|
-
@listener_factory = listener_factory
|
|
21
|
-
@debounce_seconds = debounce_seconds
|
|
22
|
-
@mutex = Mutex.new
|
|
23
|
-
@last_reload_at = nil
|
|
24
|
-
@listener = nil
|
|
25
|
-
@trailing_reload = nil
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def start
|
|
29
|
-
return self if @paths.empty? || @listener
|
|
30
|
-
|
|
31
|
-
@listener = listener_factory.to(*directories) do |modified, added, removed|
|
|
32
|
-
handle_changes(modified + added + removed)
|
|
33
|
-
end
|
|
34
|
-
@listener.start
|
|
35
|
-
self
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def stop
|
|
39
|
-
@listener&.stop
|
|
40
|
-
@listener = nil
|
|
41
|
-
@mutex.synchronize { @trailing_reload }&.kill
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def handle_changes(changed_paths)
|
|
45
|
-
return unless relevant_change?(changed_paths)
|
|
46
|
-
|
|
47
|
-
if debounced?
|
|
48
|
-
schedule_trailing_reload
|
|
49
|
-
else
|
|
50
|
-
reload(retry_on_failure: true)
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
# A failed reload clears the debounce stamp so the next change reloads
|
|
57
|
-
# immediately, and gets one retry in case no further change arrives.
|
|
58
|
-
def reload(retry_on_failure:)
|
|
59
|
-
@reload_callback.call
|
|
60
|
-
rescue StandardError
|
|
61
|
-
@mutex.synchronize { @last_reload_at = nil }
|
|
62
|
-
schedule_trailing_reload if retry_on_failure
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Cert and key rotate as separate writes, so the second one lands inside
|
|
66
|
-
# the debounce window. Flush it once the window closes instead of dropping it.
|
|
67
|
-
def schedule_trailing_reload
|
|
68
|
-
@mutex.synchronize do
|
|
69
|
-
return if @trailing_reload&.alive?
|
|
70
|
-
|
|
71
|
-
@trailing_reload = Thread.new do
|
|
72
|
-
sleep(@debounce_seconds)
|
|
73
|
-
@mutex.synchronize { @last_reload_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
74
|
-
reload(retry_on_failure: false)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def directories
|
|
80
|
-
@directories ||= @paths.map { |path| File.dirname(path) }.uniq
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def listener_factory
|
|
84
|
-
@listener_factory ||= begin
|
|
85
|
-
require "listen"
|
|
86
|
-
Listen
|
|
87
|
-
rescue LoadError => e
|
|
88
|
-
raise LoadError, "listen gem is required when tls_cert_watch is enabled: #{e.message}"
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Kubernetes projected and secret volumes rotate atomically: kubelet writes a new timestamped
|
|
93
|
-
# directory and swaps a `..data` symlink, so the watched file's own path never appears in the
|
|
94
|
-
# change events. Any change inside a watched file's directory therefore counts as relevant;
|
|
95
|
-
# the debounce absorbs the multi-event burst a rotation produces.
|
|
96
|
-
def relevant_change?(changed_paths)
|
|
97
|
-
changed_paths.any? do |path|
|
|
98
|
-
expanded = File.expand_path(path)
|
|
99
|
-
@paths.include?(expanded) || directories.any? { |dir| expanded.start_with?("#{dir}/") }
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def debounced?
|
|
104
|
-
return false unless @debounce_seconds.positive?
|
|
105
|
-
|
|
106
|
-
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
107
|
-
@mutex.synchronize do
|
|
108
|
-
return true if @last_reload_at && (now - @last_reload_at) < @debounce_seconds
|
|
109
|
-
|
|
110
|
-
@last_reload_at = now
|
|
111
|
-
false
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|