certapi 1.1.4__tar.gz → 1.1.5__tar.gz
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.
- {certapi-1.1.4/src/certapi.egg-info → certapi-1.1.5}/PKG-INFO +1 -1
- {certapi-1.1.4 → certapi-1.1.5}/setup.py +1 -1
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/renewal_manager.py +136 -6
- {certapi-1.1.4 → certapi-1.1.5/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_renewal_manager.py +95 -38
- {certapi-1.1.4 → certapi-1.1.5}/MANIFEST.in +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/README.md +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/pyproject.toml +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/setup.cfg +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/http.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/cli.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/domain_batching.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/errors.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/types.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/manager/acme_cert_manager.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/util.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi/utils.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/SOURCES.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_acme_cert_manager_batching.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_acme_error_handling.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_cli.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_domain_batching_boulder_cases.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_keystores.py +0 -0
- {certapi-1.1.4 → certapi-1.1.5}/tests/test_obtain_interface.py +0 -0
|
@@ -12,7 +12,15 @@ from certapi.manager.acme_cert_manager import DEFAULT_RENEW_THRESHOLD_DAYS
|
|
|
12
12
|
|
|
13
13
|
class RenewalManager:
|
|
14
14
|
"""
|
|
15
|
-
|
|
15
|
+
Keep certificates fresh for a changing set of watched domains.
|
|
16
|
+
|
|
17
|
+
RenewalManager can run as a background worker with :meth:`start`, while
|
|
18
|
+
callers publish the complete desired domain set through
|
|
19
|
+
:meth:`update_watch_domains`. Publishing a domain set is synchronous: the call
|
|
20
|
+
returns after certapi has processed the set and attempted any due
|
|
21
|
+
obtain/renew work. Each renewal pass first calls the optional
|
|
22
|
+
``sync_watch_domains`` callback so integrations can republish the latest
|
|
23
|
+
domain list before certapi decides what to obtain or renew.
|
|
16
24
|
|
|
17
25
|
Algorithm:
|
|
18
26
|
- Maintain a watched domain set and cache each domain's certificate expiry.
|
|
@@ -27,6 +35,14 @@ class RenewalManager:
|
|
|
27
35
|
- On failure for renewal, where a cached or local certificate exists, keep reusing
|
|
28
36
|
that existing certificate even if it is expired, defer the next retry, and do not
|
|
29
37
|
replace it with a self-signed certificate.
|
|
38
|
+
|
|
39
|
+
The manager keeps an in-memory expiry cache, bootstraps that cache from the
|
|
40
|
+
backend keystore when possible, renews missing or soon-expiring
|
|
41
|
+
certificates, and records failures in :meth:`get_state`. New domains that
|
|
42
|
+
fail issuance receive a short-lived retry blacklist and, when the backend
|
|
43
|
+
exposes a keystore, a local self-signed fallback certificate. Renewal
|
|
44
|
+
failures for domains with an existing certificate keep using that
|
|
45
|
+
certificate and schedule a later retry.
|
|
30
46
|
"""
|
|
31
47
|
|
|
32
48
|
def __init__(
|
|
@@ -52,6 +68,50 @@ class RenewalManager:
|
|
|
52
68
|
organization: Optional[str] = None,
|
|
53
69
|
user_id: Optional[str] = None,
|
|
54
70
|
):
|
|
71
|
+
"""
|
|
72
|
+
Create a renewal manager for a certificate backend.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cert_manager_client: Backend object with an ``obtain(domains,
|
|
76
|
+
**kwargs)`` method. Local managers and ``CertManagerClient`` are
|
|
77
|
+
both supported.
|
|
78
|
+
sync_watch_domains: Optional callback invoked at the start of each
|
|
79
|
+
renewal pass. The callback should call :meth:`update_watch_domains`
|
|
80
|
+
with the latest desired domain list. Calls made from this
|
|
81
|
+
callback only update the in-progress cycle and do not start a
|
|
82
|
+
nested renewal pass.
|
|
83
|
+
renew_threshold_days: Renew certificates when they expire within
|
|
84
|
+
this many days. Defaults to ``CERT_RENEW_THRESHOLD_DAYS`` or the
|
|
85
|
+
certapi default.
|
|
86
|
+
min_renew_threshold_days: Lower bound for renewal attempts, used to
|
|
87
|
+
avoid renewing too close to expiry even when a smaller threshold
|
|
88
|
+
is configured.
|
|
89
|
+
sleep_slack_seconds: Extra delay added after the calculated next
|
|
90
|
+
renewal time to avoid waking exactly on the threshold boundary.
|
|
91
|
+
max_sleep_seconds: Maximum time the background worker sleeps between
|
|
92
|
+
checks.
|
|
93
|
+
renew_retry_interval_seconds: Delay before retrying renewal for a
|
|
94
|
+
domain that already has a local certificate.
|
|
95
|
+
blacklist_duration_seconds: Delay before retrying issuance for a
|
|
96
|
+
domain that had no usable local certificate.
|
|
97
|
+
remote_poll_interval_seconds: Poll interval while waiting for remote
|
|
98
|
+
``CertManagerClient`` requests.
|
|
99
|
+
clock_fn: Optional clock override for deterministic tests.
|
|
100
|
+
sleep_fn: Optional sleep override for deterministic tests.
|
|
101
|
+
key_type: Private key type requested from the backend.
|
|
102
|
+
expiry_days: Requested certificate lifetime when the backend
|
|
103
|
+
supports it.
|
|
104
|
+
batch_domains: Forwarded to the backend to enable domain batching.
|
|
105
|
+
self_verify: Forwarded to the backend to enable or disable
|
|
106
|
+
ownership self-verification.
|
|
107
|
+
country: Optional subject country for issued or fallback certs.
|
|
108
|
+
state: Optional subject state for issued or fallback certs.
|
|
109
|
+
locality: Optional subject locality for issued or fallback certs.
|
|
110
|
+
organization: Optional subject organization for issued or fallback
|
|
111
|
+
certs.
|
|
112
|
+
user_id: Optional subject/user identifier for issued or fallback
|
|
113
|
+
certs.
|
|
114
|
+
"""
|
|
55
115
|
self.cert_manager_client = cert_manager_client
|
|
56
116
|
self.sync_watch_domains = sync_watch_domains
|
|
57
117
|
self.key_type = key_type
|
|
@@ -89,34 +149,73 @@ class RenewalManager:
|
|
|
89
149
|
self._lock = threading.Condition()
|
|
90
150
|
self._thread: Optional[threading.Thread] = None
|
|
91
151
|
self._running = False
|
|
152
|
+
self._cycle_requested = False
|
|
92
153
|
self._force_trigger = False
|
|
93
154
|
self._cycle_running = False
|
|
94
155
|
self._cycle_thread_id: Optional[int] = None
|
|
156
|
+
self._cycle_generation = 0
|
|
95
157
|
self._blacklist: Dict[str, datetime] = {}
|
|
96
158
|
self._last_error_message: Optional[str] = None
|
|
97
159
|
self._last_error_timestamp: Optional[datetime] = None
|
|
98
160
|
self._self_signer: Optional[SelfCertIssuer] = None
|
|
99
161
|
|
|
100
|
-
def
|
|
162
|
+
def update_watch_domains(self, domains: List[str]):
|
|
163
|
+
"""
|
|
164
|
+
Replace the complete set of domains and process it before returning.
|
|
165
|
+
|
|
166
|
+
Empty values are ignored. Cache and blacklist entries for domains no
|
|
167
|
+
longer being watched are dropped.
|
|
168
|
+
|
|
169
|
+
When called outside an active renewal cycle, this method immediately
|
|
170
|
+
runs or wakes a renewal pass and blocks until certapi has attempted any
|
|
171
|
+
due obtain/renew work for the published set. When called from
|
|
172
|
+
``sync_watch_domains`` during a renewal cycle, it only updates the set
|
|
173
|
+
being processed and returns immediately to avoid recursive cycles.
|
|
174
|
+
"""
|
|
101
175
|
new_watch_set = {x for x in domains if x}
|
|
102
176
|
with self._lock:
|
|
103
177
|
self._watch_domains = new_watch_set
|
|
104
178
|
self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
|
|
105
179
|
self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
|
|
106
|
-
if self.
|
|
107
|
-
self.
|
|
180
|
+
if self._cycle_thread_id == threading.get_ident():
|
|
181
|
+
self._lock.notify_all()
|
|
182
|
+
return
|
|
183
|
+
if self._running:
|
|
184
|
+
target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
|
|
185
|
+
self._cycle_requested = True
|
|
186
|
+
self._lock.notify_all()
|
|
187
|
+
while self._running and self._cycle_generation < target_generation:
|
|
188
|
+
self._lock.wait()
|
|
189
|
+
return
|
|
108
190
|
self._lock.notify_all()
|
|
109
191
|
|
|
192
|
+
self._run_cycle(force=False)
|
|
193
|
+
|
|
110
194
|
def start(self):
|
|
195
|
+
"""
|
|
196
|
+
Start the background renewal worker.
|
|
197
|
+
|
|
198
|
+
The worker immediately performs a forced renewal pass, then sleeps until
|
|
199
|
+
the next watched certificate approaches the renewal threshold or until
|
|
200
|
+
another thread publishes a new set with :meth:`update_watch_domains`.
|
|
201
|
+
Calling ``start`` while already running is a no-op.
|
|
202
|
+
"""
|
|
111
203
|
with self._lock:
|
|
112
204
|
if self._running:
|
|
113
205
|
return
|
|
114
206
|
self._running = True
|
|
115
|
-
self.
|
|
207
|
+
self._cycle_requested = True
|
|
116
208
|
self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
|
|
117
209
|
self._thread.start()
|
|
118
210
|
|
|
119
211
|
def stop(self):
|
|
212
|
+
"""
|
|
213
|
+
Stop the background renewal worker and wait briefly for it to exit.
|
|
214
|
+
|
|
215
|
+
In-flight remote certificate requests run in daemon helper threads and
|
|
216
|
+
are not waited on indefinitely; the manager stops scheduling new work
|
|
217
|
+
and joins the worker thread for up to two seconds.
|
|
218
|
+
"""
|
|
120
219
|
thread = None
|
|
121
220
|
with self._lock:
|
|
122
221
|
self._running = False
|
|
@@ -127,14 +226,43 @@ class RenewalManager:
|
|
|
127
226
|
thread.join(timeout=2)
|
|
128
227
|
|
|
129
228
|
def trigger_now(self):
|
|
229
|
+
"""
|
|
230
|
+
Run a renewal pass immediately.
|
|
231
|
+
|
|
232
|
+
When the background worker is not running, this method performs the
|
|
233
|
+
renewal pass in the caller's thread and returns after it completes. When
|
|
234
|
+
the worker is running, this method wakes it and blocks until the
|
|
235
|
+
requested renewal pass has completed.
|
|
236
|
+
|
|
237
|
+
Most integrations should call :meth:`update_watch_domains` instead. This
|
|
238
|
+
method is for forcing a pass without changing the watched domain set.
|
|
239
|
+
|
|
240
|
+
If called from inside the renewal cycle itself, the method only requests
|
|
241
|
+
a follow-up pass and returns immediately to avoid deadlock.
|
|
242
|
+
"""
|
|
130
243
|
with self._lock:
|
|
131
244
|
if self._running:
|
|
245
|
+
if self._cycle_thread_id == threading.get_ident():
|
|
246
|
+
self._force_trigger = True
|
|
247
|
+
self._lock.notify_all()
|
|
248
|
+
return
|
|
249
|
+
target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
|
|
132
250
|
self._force_trigger = True
|
|
133
251
|
self._lock.notify_all()
|
|
252
|
+
while self._running and self._cycle_generation < target_generation:
|
|
253
|
+
self._lock.wait()
|
|
134
254
|
return
|
|
135
255
|
self._run_cycle(force=True)
|
|
136
256
|
|
|
137
257
|
def get_state(self) -> Dict[str, Any]:
|
|
258
|
+
"""
|
|
259
|
+
Return a snapshot of renewal-manager state for diagnostics.
|
|
260
|
+
|
|
261
|
+
The returned dictionary includes watched domains, cache size, active
|
|
262
|
+
blacklisted domains, next cached expiry time, whether the background
|
|
263
|
+
worker is running, and the most recent error message/timestamp recorded
|
|
264
|
+
by the manager.
|
|
265
|
+
"""
|
|
138
266
|
with self._lock:
|
|
139
267
|
next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
|
|
140
268
|
return {
|
|
@@ -157,6 +285,7 @@ class RenewalManager:
|
|
|
157
285
|
if not self._running:
|
|
158
286
|
return
|
|
159
287
|
force = self._force_trigger
|
|
288
|
+
self._cycle_requested = False
|
|
160
289
|
self._force_trigger = False
|
|
161
290
|
|
|
162
291
|
attempt_count = self._run_cycle(force=force)
|
|
@@ -164,7 +293,7 @@ class RenewalManager:
|
|
|
164
293
|
with self._lock:
|
|
165
294
|
if not self._running:
|
|
166
295
|
return
|
|
167
|
-
if self._force_trigger:
|
|
296
|
+
if self._force_trigger or self._cycle_requested:
|
|
168
297
|
continue
|
|
169
298
|
wait_seconds = self._compute_wait_seconds(self.clock_fn())
|
|
170
299
|
|
|
@@ -233,6 +362,7 @@ class RenewalManager:
|
|
|
233
362
|
with self._lock:
|
|
234
363
|
self._cycle_running = False
|
|
235
364
|
self._cycle_thread_id = None
|
|
365
|
+
self._cycle_generation += 1
|
|
236
366
|
self._lock.notify_all()
|
|
237
367
|
|
|
238
368
|
def _run_cycle_body(self, force: bool = False) -> int:
|
|
@@ -158,7 +158,7 @@ def test_default_threshold_and_min_floor(monkeypatch):
|
|
|
158
158
|
def test_sleep_computation_slack_and_cap():
|
|
159
159
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
160
160
|
mgr = RenewalManager(DummyClient(), renew_threshold_days=30)
|
|
161
|
-
mgr.
|
|
161
|
+
mgr.update_watch_domains(["example.com"])
|
|
162
162
|
|
|
163
163
|
with mgr._lock:
|
|
164
164
|
mgr._cache["example.com"] = now + timedelta(days=60)
|
|
@@ -187,8 +187,8 @@ def test_existing_cert_failure_sets_threshold_plus_24h_retry():
|
|
|
187
187
|
renew_threshold_days=30,
|
|
188
188
|
clock_fn=lambda: clock["now"],
|
|
189
189
|
)
|
|
190
|
-
mgr.set_watch_domains(["example.com"])
|
|
191
190
|
with mgr._lock:
|
|
191
|
+
mgr._watch_domains = {"example.com"}
|
|
192
192
|
mgr._cache["example.com"] = now - timedelta(hours=1)
|
|
193
193
|
|
|
194
194
|
mgr.trigger_now()
|
|
@@ -222,7 +222,7 @@ def test_existing_cert_failure_does_not_selfsign():
|
|
|
222
222
|
client.set_handler("existing.example.com", RuntimeError("renew failed"))
|
|
223
223
|
|
|
224
224
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
225
|
-
mgr.
|
|
225
|
+
mgr.update_watch_domains(["existing.example.com"])
|
|
226
226
|
with mgr._lock:
|
|
227
227
|
mgr._cache["existing.example.com"] = now - timedelta(hours=1)
|
|
228
228
|
|
|
@@ -255,9 +255,7 @@ def test_expired_local_cert_failure_keeps_existing_cert_and_defers_retry():
|
|
|
255
255
|
client.set_handler("expired.example.com", RuntimeError("renew failed"))
|
|
256
256
|
|
|
257
257
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
258
|
-
mgr.
|
|
259
|
-
|
|
260
|
-
mgr.trigger_now()
|
|
258
|
+
mgr.update_watch_domains(["expired.example.com"])
|
|
261
259
|
|
|
262
260
|
expected = now + timedelta(seconds=mgr.update_threshold_secs + mgr.renew_retry_interval_seconds)
|
|
263
261
|
with mgr._lock:
|
|
@@ -293,8 +291,7 @@ def test_expired_sqlite_cert_failure_does_not_seed_selfsigned_as_fresh():
|
|
|
293
291
|
client.set_handler("sqlite-expired.example.com", RuntimeError("renew failed"))
|
|
294
292
|
|
|
295
293
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
296
|
-
mgr.
|
|
297
|
-
mgr.trigger_now()
|
|
294
|
+
mgr.update_watch_domains(["sqlite-expired.example.com"])
|
|
298
295
|
|
|
299
296
|
with mgr._lock:
|
|
300
297
|
assert "sqlite-expired.example.com" in mgr._cache
|
|
@@ -312,7 +309,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
|
|
|
312
309
|
client.set_handler("retry.example.com", RuntimeError("renew failed"))
|
|
313
310
|
|
|
314
311
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: clock["now"])
|
|
315
|
-
mgr.
|
|
312
|
+
mgr.update_watch_domains(["retry.example.com"])
|
|
316
313
|
with mgr._lock:
|
|
317
314
|
mgr._cache["retry.example.com"] = now - timedelta(minutes=1)
|
|
318
315
|
|
|
@@ -340,15 +337,15 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
|
|
|
340
337
|
mgr = None
|
|
341
338
|
|
|
342
339
|
def sync_watch_domains():
|
|
343
|
-
mgr.
|
|
340
|
+
mgr.update_watch_domains(["c.example.com"])
|
|
344
341
|
|
|
345
342
|
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
346
|
-
mgr.
|
|
343
|
+
mgr.update_watch_domains(["a.example.com", "b.example.com"])
|
|
347
344
|
with mgr._lock:
|
|
348
345
|
mgr._cache["a.example.com"] = now + timedelta(days=20)
|
|
349
346
|
mgr._cache["b.example.com"] = now + timedelta(days=20)
|
|
350
347
|
|
|
351
|
-
mgr.
|
|
348
|
+
mgr.update_watch_domains(["a.example.com"])
|
|
352
349
|
with mgr._lock:
|
|
353
350
|
assert "b.example.com" not in mgr._cache
|
|
354
351
|
|
|
@@ -378,7 +375,7 @@ def test_bootstrap_and_cache_update_from_issued_and_existing():
|
|
|
378
375
|
)
|
|
379
376
|
|
|
380
377
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
381
|
-
mgr.
|
|
378
|
+
mgr.update_watch_domains(["issued.example.com", "existing.example.com"])
|
|
382
379
|
|
|
383
380
|
# No cache initially; both should be obtained immediately to bootstrap.
|
|
384
381
|
mgr._run_cycle(force=False)
|
|
@@ -404,8 +401,7 @@ def test_renewal_manager_prefers_obtain_when_available():
|
|
|
404
401
|
)
|
|
405
402
|
|
|
406
403
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
407
|
-
mgr.
|
|
408
|
-
mgr._run_cycle(force=False)
|
|
404
|
+
mgr.update_watch_domains(["prefer-obtain.example.com"])
|
|
409
405
|
|
|
410
406
|
assert len(client.obtain_calls) == 1
|
|
411
407
|
assert len(client.issue_calls) == 0
|
|
@@ -428,8 +424,7 @@ def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
|
|
|
428
424
|
self_verify=False,
|
|
429
425
|
organization="certapi-tests",
|
|
430
426
|
)
|
|
431
|
-
mgr.
|
|
432
|
-
mgr.trigger_now()
|
|
427
|
+
mgr.update_watch_domains(["batch.example.com"])
|
|
433
428
|
|
|
434
429
|
assert len(client.calls) == 1
|
|
435
430
|
assert client.calls[0]["kwargs"]["batch_domains"] is True
|
|
@@ -461,8 +456,7 @@ def test_local_keystore_seed_prevents_remote_call_when_fresh():
|
|
|
461
456
|
client.key_store.set_domain_cert("fresh.example.com", [cert])
|
|
462
457
|
|
|
463
458
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
464
|
-
mgr.
|
|
465
|
-
mgr._run_cycle(force=False)
|
|
459
|
+
mgr.update_watch_domains(["fresh.example.com"])
|
|
466
460
|
|
|
467
461
|
with mgr._lock:
|
|
468
462
|
assert "fresh.example.com" in mgr._cache
|
|
@@ -496,8 +490,7 @@ def test_local_keystore_seed_stale_cert_still_renews():
|
|
|
496
490
|
)
|
|
497
491
|
|
|
498
492
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
499
|
-
mgr.
|
|
500
|
-
mgr._run_cycle(force=False)
|
|
493
|
+
mgr.update_watch_domains(["stale.example.com"])
|
|
501
494
|
|
|
502
495
|
assert len(client.calls) == 1
|
|
503
496
|
with mgr._lock:
|
|
@@ -517,9 +510,8 @@ def test_new_domain_failure_selfsigns_and_blacklists():
|
|
|
517
510
|
blacklist_duration_seconds=180,
|
|
518
511
|
clock_fn=lambda: clock["now"],
|
|
519
512
|
)
|
|
520
|
-
mgr.
|
|
513
|
+
mgr.update_watch_domains(["new.example.com"])
|
|
521
514
|
|
|
522
|
-
mgr._run_cycle(force=False)
|
|
523
515
|
assert len(client.calls) == 1
|
|
524
516
|
assert len(client.key_store.saved_certs) == 1
|
|
525
517
|
assert client.key_store.saved_certs[0][2] == "new.example.com.selfsigned"
|
|
@@ -550,19 +542,37 @@ def test_sync_callback_exception_sets_state_error():
|
|
|
550
542
|
assert state["last_error_timestamp"] == now.isoformat()
|
|
551
543
|
|
|
552
544
|
|
|
553
|
-
def
|
|
545
|
+
def test_update_watch_domains_blocks_until_running_worker_processes_domains():
|
|
546
|
+
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
547
|
+
started = threading.Event()
|
|
548
|
+
release = threading.Event()
|
|
554
549
|
client = DummyClient()
|
|
555
|
-
|
|
556
|
-
with mgr._lock:
|
|
557
|
-
mgr._running = True
|
|
550
|
+
cert = _make_cert_pem("requested.example.com", now, valid_for_days=90)
|
|
558
551
|
|
|
559
|
-
|
|
552
|
+
def slow_handler(host, kwargs):
|
|
553
|
+
started.set()
|
|
554
|
+
release.wait(timeout=2)
|
|
555
|
+
return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
|
|
560
556
|
|
|
561
|
-
|
|
562
|
-
|
|
557
|
+
client.set_handler("requested.example.com", slow_handler)
|
|
558
|
+
mgr = RenewalManager(client, clock_fn=lambda: now)
|
|
559
|
+
mgr.start()
|
|
560
|
+
|
|
561
|
+
update_thread = threading.Thread(target=lambda: mgr.update_watch_domains(["requested.example.com"]))
|
|
562
|
+
update_thread.start()
|
|
563
|
+
|
|
564
|
+
assert started.wait(timeout=2)
|
|
565
|
+
assert update_thread.is_alive()
|
|
566
|
+
release.set()
|
|
567
|
+
update_thread.join(timeout=2)
|
|
568
|
+
mgr.stop()
|
|
569
|
+
|
|
570
|
+
assert not update_thread.is_alive()
|
|
571
|
+
assert len(client.calls) == 1
|
|
572
|
+
assert client.calls[0]["host"] == "requested.example.com"
|
|
563
573
|
|
|
564
574
|
|
|
565
|
-
def
|
|
575
|
+
def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
566
576
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
567
577
|
client = DummyClient()
|
|
568
578
|
cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
|
|
@@ -573,7 +583,7 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
|
573
583
|
mgr = None
|
|
574
584
|
|
|
575
585
|
def sync_watch_domains():
|
|
576
|
-
mgr.
|
|
586
|
+
mgr.update_watch_domains(["from-callback.example.com"])
|
|
577
587
|
|
|
578
588
|
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
579
589
|
with mgr._lock:
|
|
@@ -583,9 +593,55 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
|
583
593
|
|
|
584
594
|
with mgr._lock:
|
|
585
595
|
assert mgr._force_trigger is False
|
|
596
|
+
assert mgr._cycle_requested is False
|
|
586
597
|
assert len(client.calls) == 1
|
|
587
598
|
|
|
588
599
|
|
|
600
|
+
def test_running_trigger_now_blocks_until_synced_cycle_finishes():
|
|
601
|
+
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
602
|
+
sync_count = 0
|
|
603
|
+
sync_ready = threading.Event()
|
|
604
|
+
sync_enabled = threading.Event()
|
|
605
|
+
obtain_started = threading.Event()
|
|
606
|
+
release_obtain = threading.Event()
|
|
607
|
+
client = DummyClient()
|
|
608
|
+
cert = _make_cert_pem("blocking.example.com", now, valid_for_days=90)
|
|
609
|
+
mgr = None
|
|
610
|
+
|
|
611
|
+
def slow_handler(host, kwargs):
|
|
612
|
+
obtain_started.set()
|
|
613
|
+
release_obtain.wait(timeout=2)
|
|
614
|
+
return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
|
|
615
|
+
|
|
616
|
+
def sync_watch_domains():
|
|
617
|
+
nonlocal sync_count
|
|
618
|
+
sync_count += 1
|
|
619
|
+
sync_ready.set()
|
|
620
|
+
if sync_enabled.is_set():
|
|
621
|
+
mgr.update_watch_domains(["blocking.example.com"])
|
|
622
|
+
|
|
623
|
+
client.set_handler("blocking.example.com", slow_handler)
|
|
624
|
+
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
625
|
+
|
|
626
|
+
mgr.start()
|
|
627
|
+
assert sync_ready.wait(timeout=2)
|
|
628
|
+
|
|
629
|
+
sync_enabled.set()
|
|
630
|
+
trigger_thread = threading.Thread(target=mgr.trigger_now)
|
|
631
|
+
trigger_thread.start()
|
|
632
|
+
|
|
633
|
+
assert obtain_started.wait(timeout=2)
|
|
634
|
+
assert trigger_thread.is_alive()
|
|
635
|
+
release_obtain.set()
|
|
636
|
+
trigger_thread.join(timeout=2)
|
|
637
|
+
|
|
638
|
+
mgr.stop()
|
|
639
|
+
assert not trigger_thread.is_alive()
|
|
640
|
+
assert sync_count >= 2
|
|
641
|
+
assert len(client.calls) == 1
|
|
642
|
+
assert client.calls[0]["host"] == "blocking.example.com"
|
|
643
|
+
|
|
644
|
+
|
|
589
645
|
def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
590
646
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
591
647
|
started = threading.Event()
|
|
@@ -600,7 +656,8 @@ def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
|
600
656
|
|
|
601
657
|
client.set_handler("singleflight.example.com", slow_handler)
|
|
602
658
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
603
|
-
mgr.
|
|
659
|
+
with mgr._lock:
|
|
660
|
+
mgr._watch_domains = {"singleflight.example.com"}
|
|
604
661
|
|
|
605
662
|
first = threading.Thread(target=lambda: mgr.trigger_now())
|
|
606
663
|
first.start()
|
|
@@ -630,7 +687,8 @@ def test_remote_certapi_polling_prints_waiting_message(capsys):
|
|
|
630
687
|
remote_poll_interval_seconds=0.01,
|
|
631
688
|
clock_fn=lambda: now,
|
|
632
689
|
)
|
|
633
|
-
mgr.
|
|
690
|
+
with mgr._lock:
|
|
691
|
+
mgr._watch_domains = {"remote.example.com"}
|
|
634
692
|
|
|
635
693
|
thread = threading.Thread(target=lambda: mgr.trigger_now())
|
|
636
694
|
thread.start()
|
|
@@ -651,9 +709,7 @@ def test_remote_renewal_disables_skip_failing_so_unverified_domain_fails():
|
|
|
651
709
|
client.key_store = DummyKeyStore()
|
|
652
710
|
|
|
653
711
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
654
|
-
mgr.
|
|
655
|
-
|
|
656
|
-
mgr.trigger_now()
|
|
712
|
+
mgr.update_watch_domains(["missing.example.com"])
|
|
657
713
|
|
|
658
714
|
assert len(client.obtain_calls) == 1
|
|
659
715
|
assert client.obtain_calls[0]["kwargs"]["skip_failing"] is False
|
|
@@ -668,7 +724,8 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
|
|
|
668
724
|
release = threading.Event()
|
|
669
725
|
client = DummyRemoteClient(started=started, release=release)
|
|
670
726
|
mgr = RenewalManager(client, remote_poll_interval_seconds=0.01)
|
|
671
|
-
mgr.
|
|
727
|
+
with mgr._lock:
|
|
728
|
+
mgr._watch_domains = {"hung.example.com"}
|
|
672
729
|
|
|
673
730
|
mgr.start()
|
|
674
731
|
assert started.wait(timeout=2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|