certapi 1.1.3__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.3/src/certapi.egg-info → certapi-1.1.5}/PKG-INFO +1 -1
- {certapi-1.1.3 → certapi-1.1.5}/setup.py +1 -1
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/renewal_manager.py +173 -21
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/manager/acme_cert_manager.py +14 -1
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/api.py +17 -35
- {certapi-1.1.3 → certapi-1.1.5/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_acme_cert_manager_batching.py +40 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_obtain_interface.py +15 -5
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_renewal_manager.py +127 -39
- {certapi-1.1.3 → certapi-1.1.5}/MANIFEST.in +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/README.md +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/pyproject.toml +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/setup.cfg +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/http.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/cli.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/domain_batching.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/errors.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/types.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/util.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi/utils.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/SOURCES.txt +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_acme_error_handling.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_cli.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_domain_batching_boulder_cases.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.3 → certapi-1.1.5}/tests/test_keystores.py +0 -0
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
4
|
from datetime import datetime, timezone, timedelta
|
|
5
|
-
from typing import Callable, Dict, Optional, Set, Any, List
|
|
5
|
+
from typing import Callable, Dict, Optional, Set, Any, List, Literal
|
|
6
6
|
|
|
7
7
|
from certapi.crypto import Key, certs_from_pem
|
|
8
8
|
from certapi.client.cert_manager_client import CertManagerClient
|
|
@@ -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__(
|
|
@@ -42,9 +58,71 @@ class RenewalManager:
|
|
|
42
58
|
remote_poll_interval_seconds: int = 30,
|
|
43
59
|
clock_fn: Optional[Callable[[], datetime]] = None,
|
|
44
60
|
sleep_fn: Optional[Callable[[float], None]] = None,
|
|
61
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
62
|
+
expiry_days: int = 90,
|
|
63
|
+
batch_domains: bool = False,
|
|
64
|
+
self_verify: bool = True,
|
|
65
|
+
country: Optional[str] = None,
|
|
66
|
+
state: Optional[str] = None,
|
|
67
|
+
locality: Optional[str] = None,
|
|
68
|
+
organization: Optional[str] = None,
|
|
69
|
+
user_id: Optional[str] = None,
|
|
45
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
|
+
"""
|
|
46
115
|
self.cert_manager_client = cert_manager_client
|
|
47
116
|
self.sync_watch_domains = sync_watch_domains
|
|
117
|
+
self.key_type = key_type
|
|
118
|
+
self.expiry_days = expiry_days
|
|
119
|
+
self.batch_domains = batch_domains
|
|
120
|
+
self.self_verify = self_verify
|
|
121
|
+
self.country = country
|
|
122
|
+
self.state = state
|
|
123
|
+
self.locality = locality
|
|
124
|
+
self.organization = organization
|
|
125
|
+
self.user_id = user_id
|
|
48
126
|
self.sleep_slack_seconds = sleep_slack_seconds
|
|
49
127
|
self.max_sleep_seconds = max_sleep_seconds
|
|
50
128
|
self.renew_retry_interval_seconds = renew_retry_interval_seconds
|
|
@@ -71,34 +149,73 @@ class RenewalManager:
|
|
|
71
149
|
self._lock = threading.Condition()
|
|
72
150
|
self._thread: Optional[threading.Thread] = None
|
|
73
151
|
self._running = False
|
|
152
|
+
self._cycle_requested = False
|
|
74
153
|
self._force_trigger = False
|
|
75
154
|
self._cycle_running = False
|
|
76
155
|
self._cycle_thread_id: Optional[int] = None
|
|
156
|
+
self._cycle_generation = 0
|
|
77
157
|
self._blacklist: Dict[str, datetime] = {}
|
|
78
158
|
self._last_error_message: Optional[str] = None
|
|
79
159
|
self._last_error_timestamp: Optional[datetime] = None
|
|
80
160
|
self._self_signer: Optional[SelfCertIssuer] = None
|
|
81
161
|
|
|
82
|
-
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
|
+
"""
|
|
83
175
|
new_watch_set = {x for x in domains if x}
|
|
84
176
|
with self._lock:
|
|
85
177
|
self._watch_domains = new_watch_set
|
|
86
178
|
self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
|
|
87
179
|
self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
|
|
88
|
-
if self.
|
|
89
|
-
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
|
|
90
190
|
self._lock.notify_all()
|
|
91
191
|
|
|
192
|
+
self._run_cycle(force=False)
|
|
193
|
+
|
|
92
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
|
+
"""
|
|
93
203
|
with self._lock:
|
|
94
204
|
if self._running:
|
|
95
205
|
return
|
|
96
206
|
self._running = True
|
|
97
|
-
self.
|
|
207
|
+
self._cycle_requested = True
|
|
98
208
|
self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
|
|
99
209
|
self._thread.start()
|
|
100
210
|
|
|
101
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
|
+
"""
|
|
102
219
|
thread = None
|
|
103
220
|
with self._lock:
|
|
104
221
|
self._running = False
|
|
@@ -109,14 +226,43 @@ class RenewalManager:
|
|
|
109
226
|
thread.join(timeout=2)
|
|
110
227
|
|
|
111
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
|
+
"""
|
|
112
243
|
with self._lock:
|
|
113
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)
|
|
114
250
|
self._force_trigger = True
|
|
115
251
|
self._lock.notify_all()
|
|
252
|
+
while self._running and self._cycle_generation < target_generation:
|
|
253
|
+
self._lock.wait()
|
|
116
254
|
return
|
|
117
255
|
self._run_cycle(force=True)
|
|
118
256
|
|
|
119
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
|
+
"""
|
|
120
266
|
with self._lock:
|
|
121
267
|
next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
|
|
122
268
|
return {
|
|
@@ -139,6 +285,7 @@ class RenewalManager:
|
|
|
139
285
|
if not self._running:
|
|
140
286
|
return
|
|
141
287
|
force = self._force_trigger
|
|
288
|
+
self._cycle_requested = False
|
|
142
289
|
self._force_trigger = False
|
|
143
290
|
|
|
144
291
|
attempt_count = self._run_cycle(force=force)
|
|
@@ -146,7 +293,7 @@ class RenewalManager:
|
|
|
146
293
|
with self._lock:
|
|
147
294
|
if not self._running:
|
|
148
295
|
return
|
|
149
|
-
if self._force_trigger:
|
|
296
|
+
if self._force_trigger or self._cycle_requested:
|
|
150
297
|
continue
|
|
151
298
|
wait_seconds = self._compute_wait_seconds(self.clock_fn())
|
|
152
299
|
|
|
@@ -215,6 +362,7 @@ class RenewalManager:
|
|
|
215
362
|
with self._lock:
|
|
216
363
|
self._cycle_running = False
|
|
217
364
|
self._cycle_thread_id = None
|
|
365
|
+
self._cycle_generation += 1
|
|
218
366
|
self._lock.notify_all()
|
|
219
367
|
|
|
220
368
|
def _run_cycle_body(self, force: bool = False) -> int:
|
|
@@ -293,20 +441,24 @@ class RenewalManager:
|
|
|
293
441
|
|
|
294
442
|
try:
|
|
295
443
|
renew_threshold_days = self.cert_min_renew_threshold_secs // (24 * 3600)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
444
|
+
obtain_options = {
|
|
445
|
+
"key_type": self.key_type,
|
|
446
|
+
"expiry_days": self.expiry_days,
|
|
447
|
+
"renew_threshold_days": renew_threshold_days,
|
|
448
|
+
"skip_failing": False,
|
|
449
|
+
"batch_domains": self.batch_domains,
|
|
450
|
+
"self_verify": self.self_verify,
|
|
451
|
+
"country": self.country,
|
|
452
|
+
"state": self.state,
|
|
453
|
+
"locality": self.locality,
|
|
454
|
+
"organization": self.organization,
|
|
455
|
+
"user_id": self.user_id,
|
|
456
|
+
}
|
|
457
|
+
res = self._call_certificate_backend(
|
|
458
|
+
self.cert_manager_client.obtain,
|
|
459
|
+
[domain],
|
|
460
|
+
obtain_options,
|
|
461
|
+
)
|
|
310
462
|
if res is None:
|
|
311
463
|
return
|
|
312
464
|
self._update_expiry_cache(res.issued + res.existing)
|
|
@@ -75,6 +75,8 @@ class AcmeCertManager:
|
|
|
75
75
|
organization: Optional[str] = None,
|
|
76
76
|
user_id: Optional[str] = None,
|
|
77
77
|
renew_threshold_days: Optional[int] = None,
|
|
78
|
+
skip_failing: bool = True,
|
|
79
|
+
batch_domains: bool = False,
|
|
78
80
|
self_verify: bool = True,
|
|
79
81
|
) -> CertificateResponse:
|
|
80
82
|
return self.obtain(
|
|
@@ -87,6 +89,8 @@ class AcmeCertManager:
|
|
|
87
89
|
organization=organization,
|
|
88
90
|
user_id=user_id,
|
|
89
91
|
renew_threshold_days=renew_threshold_days,
|
|
92
|
+
skip_failing=skip_failing,
|
|
93
|
+
batch_domains=batch_domains,
|
|
90
94
|
self_verify=self_verify,
|
|
91
95
|
)
|
|
92
96
|
|
|
@@ -101,6 +105,8 @@ class AcmeCertManager:
|
|
|
101
105
|
organization: Optional[str] = None,
|
|
102
106
|
user_id: Optional[str] = None,
|
|
103
107
|
renew_threshold_days: Optional[int] = None,
|
|
108
|
+
skip_failing: bool = True,
|
|
109
|
+
batch_domains: bool = False,
|
|
104
110
|
self_verify: bool = True,
|
|
105
111
|
) -> CertificateResponse:
|
|
106
112
|
return self._issue_certificate_internal(
|
|
@@ -113,7 +119,8 @@ class AcmeCertManager:
|
|
|
113
119
|
organization=organization,
|
|
114
120
|
user_id=user_id,
|
|
115
121
|
renew_threshold_days=renew_threshold_days,
|
|
116
|
-
batch_generator=None,
|
|
122
|
+
batch_generator=create_safe_domain_batches if batch_domains else None,
|
|
123
|
+
skip_failing=skip_failing,
|
|
117
124
|
self_verify=self_verify,
|
|
118
125
|
)
|
|
119
126
|
|
|
@@ -129,6 +136,7 @@ class AcmeCertManager:
|
|
|
129
136
|
user_id: Optional[str] = None,
|
|
130
137
|
renew_threshold_days: Optional[int] = None,
|
|
131
138
|
batch_generator: Callable[[List[str]], List[List[str]]] = create_safe_domain_batches,
|
|
139
|
+
skip_failing: bool = True,
|
|
132
140
|
self_verify: bool = True,
|
|
133
141
|
) -> CertificateResponse:
|
|
134
142
|
return self._issue_certificate_internal(
|
|
@@ -142,6 +150,7 @@ class AcmeCertManager:
|
|
|
142
150
|
user_id=user_id,
|
|
143
151
|
renew_threshold_days=renew_threshold_days,
|
|
144
152
|
batch_generator=batch_generator,
|
|
153
|
+
skip_failing=skip_failing,
|
|
145
154
|
self_verify=self_verify,
|
|
146
155
|
)
|
|
147
156
|
|
|
@@ -157,6 +166,7 @@ class AcmeCertManager:
|
|
|
157
166
|
user_id: Optional[str] = None,
|
|
158
167
|
renew_threshold_days: Optional[int] = None,
|
|
159
168
|
batch_generator: Optional[Callable[[List[str]], List[List[str]]]] = None,
|
|
169
|
+
skip_failing: bool = True,
|
|
160
170
|
self_verify: bool = True,
|
|
161
171
|
) -> CertificateResponse:
|
|
162
172
|
if isinstance(hosts, str):
|
|
@@ -196,6 +206,9 @@ class AcmeCertManager:
|
|
|
196
206
|
else:
|
|
197
207
|
print(f"Warning: No challenge solver found that supports domain: {host}. Skipping.")
|
|
198
208
|
|
|
209
|
+
if len(domains_by_store) == 0 and not skip_failing:
|
|
210
|
+
raise ValueError("None of the domains are owned by this machine or could be verified")
|
|
211
|
+
|
|
199
212
|
for store, domains_to_issue in domains_by_store.items():
|
|
200
213
|
issuance_batches = (
|
|
201
214
|
batch_generator(domains_to_issue) if batch_generator is not None else [domains_to_issue]
|
|
@@ -122,43 +122,25 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
122
122
|
def get(self):
|
|
123
123
|
args = obtain_parser.parse_args()
|
|
124
124
|
hostnames = args["hostname"]
|
|
125
|
-
skip_failing = args.get("skip_failing", False)
|
|
126
|
-
self_verify = args.get("self_verify", True)
|
|
127
125
|
|
|
128
126
|
with lock_manager:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
cert_manager.issue_certificate_in_batches
|
|
147
|
-
if args.get("batch_domains")
|
|
148
|
-
else cert_manager.issue_certificate
|
|
149
|
-
)
|
|
150
|
-
data = issue_fn(
|
|
151
|
-
hostnames,
|
|
152
|
-
key_type=args["key_type"],
|
|
153
|
-
expiry_days=args["expiry_days"],
|
|
154
|
-
country=args["country"],
|
|
155
|
-
state=args["state"],
|
|
156
|
-
locality=args["locality"],
|
|
157
|
-
organization=args["organization"],
|
|
158
|
-
user_id=args["user_id"],
|
|
159
|
-
renew_threshold_days=args.get("renew_threshold_days"),
|
|
160
|
-
self_verify=False,
|
|
161
|
-
)
|
|
127
|
+
try:
|
|
128
|
+
data = cert_manager.obtain(
|
|
129
|
+
hostnames,
|
|
130
|
+
key_type=args["key_type"],
|
|
131
|
+
expiry_days=args["expiry_days"],
|
|
132
|
+
country=args["country"],
|
|
133
|
+
state=args["state"],
|
|
134
|
+
locality=args["locality"],
|
|
135
|
+
organization=args["organization"],
|
|
136
|
+
user_id=args["user_id"],
|
|
137
|
+
renew_threshold_days=args.get("renew_threshold_days"),
|
|
138
|
+
skip_failing=args.get("skip_failing", False),
|
|
139
|
+
batch_domains=args.get("batch_domains", False),
|
|
140
|
+
self_verify=args.get("self_verify", True),
|
|
141
|
+
)
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
api_ns.abort(400, message=str(e))
|
|
162
144
|
|
|
163
145
|
print(data)
|
|
164
146
|
if data:
|
|
@@ -98,6 +98,26 @@ def test_issue_certificate_in_batches_uses_custom_batch_generator():
|
|
|
98
98
|
assert [issued.domains for issued in response.issued] == issuer.calls
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def test_obtain_batch_domains_uses_safe_batches():
|
|
102
|
+
key_store = DummyKeyStore()
|
|
103
|
+
issuer = DummyIssuer()
|
|
104
|
+
solver = DummySolver()
|
|
105
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
106
|
+
|
|
107
|
+
domains = [
|
|
108
|
+
"x.abc.def.ghi.example.com",
|
|
109
|
+
"root.example.com",
|
|
110
|
+
]
|
|
111
|
+
response = manager.obtain(hosts=domains, batch_domains=True)
|
|
112
|
+
|
|
113
|
+
assert issuer.calls == [
|
|
114
|
+
["root.example.com"],
|
|
115
|
+
["x.abc.def"],
|
|
116
|
+
["ghi.example.com"],
|
|
117
|
+
]
|
|
118
|
+
assert len(response.issued) == 3
|
|
119
|
+
|
|
120
|
+
|
|
101
121
|
def test_issue_certificate_self_verify_false_skips_strict_solver_check():
|
|
102
122
|
class StrictFailingSolver(DummySolver):
|
|
103
123
|
def supports_domain_strict(self, _domain):
|
|
@@ -127,3 +147,23 @@ def test_issue_certificate_self_verify_true_uses_strict_solver_check():
|
|
|
127
147
|
|
|
128
148
|
assert issuer.calls == []
|
|
129
149
|
assert response.issued == []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_obtain_self_verify_true_raises_when_all_domains_fail_and_skip_failing_false():
|
|
153
|
+
class StrictFailingSolver(DummySolver):
|
|
154
|
+
def supports_domain_strict(self, _domain):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
key_store = DummyKeyStore()
|
|
158
|
+
issuer = DummyIssuer()
|
|
159
|
+
solver = StrictFailingSolver()
|
|
160
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
manager.obtain(hosts=["force.example.com"], skip_failing=False)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
assert "None of the domains are owned" in str(e)
|
|
166
|
+
else:
|
|
167
|
+
raise AssertionError("expected strict obtain failure")
|
|
168
|
+
|
|
169
|
+
assert issuer.calls == []
|
|
@@ -4,6 +4,7 @@ from flask import Flask
|
|
|
4
4
|
from flask_restx import Api, Namespace
|
|
5
5
|
|
|
6
6
|
from certapi.client.cert_manager_client import CertManagerClient
|
|
7
|
+
from certapi.domain_batching import create_safe_domain_batches
|
|
7
8
|
from certapi.http.types import CertificateResponse
|
|
8
9
|
from certapi.manager.acme_cert_manager import AcmeCertManager
|
|
9
10
|
from certapi.server.api import create_api_resources
|
|
@@ -29,6 +30,8 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
29
30
|
organization="Org",
|
|
30
31
|
user_id="u1",
|
|
31
32
|
renew_threshold_days=25,
|
|
33
|
+
skip_failing=False,
|
|
34
|
+
batch_domains=True,
|
|
32
35
|
self_verify=False,
|
|
33
36
|
)
|
|
34
37
|
|
|
@@ -42,6 +45,8 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
42
45
|
assert captured["organization"] == "Org"
|
|
43
46
|
assert captured["user_id"] == "u1"
|
|
44
47
|
assert captured["renew_threshold_days"] == 25
|
|
48
|
+
assert captured["skip_failing"] is False
|
|
49
|
+
assert captured["batch_domains"] is True
|
|
45
50
|
assert captured["self_verify"] is False
|
|
46
51
|
|
|
47
52
|
|
|
@@ -60,6 +65,8 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
|
|
|
60
65
|
key_type="ecdsa",
|
|
61
66
|
expiry_days=90,
|
|
62
67
|
renew_threshold_days=30,
|
|
68
|
+
skip_failing=False,
|
|
69
|
+
batch_domains=True,
|
|
63
70
|
self_verify=False,
|
|
64
71
|
)
|
|
65
72
|
|
|
@@ -68,7 +75,8 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
|
|
|
68
75
|
assert captured["key_type"] == "ecdsa"
|
|
69
76
|
assert captured["expiry_days"] == 90
|
|
70
77
|
assert captured["renew_threshold_days"] == 30
|
|
71
|
-
assert captured["batch_generator"] is
|
|
78
|
+
assert captured["batch_generator"] is create_safe_domain_batches
|
|
79
|
+
assert captured["skip_failing"] is False
|
|
72
80
|
assert captured["self_verify"] is False
|
|
73
81
|
|
|
74
82
|
|
|
@@ -157,7 +165,7 @@ def test_remote_obtain_self_verify_false_skips_server_prefilter():
|
|
|
157
165
|
def __init__(self):
|
|
158
166
|
self.calls = []
|
|
159
167
|
|
|
160
|
-
def
|
|
168
|
+
def obtain(self, hosts, **kwargs):
|
|
161
169
|
self.calls.append({"hosts": hosts, "kwargs": kwargs})
|
|
162
170
|
return CertificateResponse()
|
|
163
171
|
|
|
@@ -171,7 +179,7 @@ def test_remote_obtain_self_verify_false_skips_server_prefilter():
|
|
|
171
179
|
assert manager.calls[0]["kwargs"]["self_verify"] is False
|
|
172
180
|
|
|
173
181
|
|
|
174
|
-
def
|
|
182
|
+
def test_remote_obtain_self_verify_true_delegates_strict_failure_to_manager():
|
|
175
183
|
app = Flask(__name__)
|
|
176
184
|
api = Api(app)
|
|
177
185
|
api_ns = Namespace("api")
|
|
@@ -184,8 +192,10 @@ def test_remote_obtain_self_verify_true_keeps_server_prefilter():
|
|
|
184
192
|
class Manager:
|
|
185
193
|
challenge_solvers = [Solver()]
|
|
186
194
|
|
|
187
|
-
def
|
|
188
|
-
|
|
195
|
+
def obtain(self, hosts, **kwargs):
|
|
196
|
+
assert hosts == ["force.example.com"]
|
|
197
|
+
assert kwargs["self_verify"] is True
|
|
198
|
+
raise ValueError("None of the domains are owned by this machine or could be verified")
|
|
189
199
|
|
|
190
200
|
create_api_resources(api_ns, Manager())
|
|
191
201
|
|
|
@@ -23,7 +23,7 @@ class DummyClient:
|
|
|
23
23
|
def set_handler(self, domain, handler):
|
|
24
24
|
self._handlers[domain] = handler
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def obtain(self, hosts, **kwargs):
|
|
27
27
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
28
28
|
self.calls.append({"host": host, "kwargs": kwargs})
|
|
29
29
|
handler = self._handlers.get(host)
|
|
@@ -35,6 +35,9 @@ class DummyClient:
|
|
|
35
35
|
return handler(host, kwargs)
|
|
36
36
|
return handler
|
|
37
37
|
|
|
38
|
+
def issue_certificate(self, hosts, **kwargs):
|
|
39
|
+
return self.obtain(hosts, **kwargs)
|
|
40
|
+
|
|
38
41
|
|
|
39
42
|
class DummyClientWithObtain(DummyClient):
|
|
40
43
|
def __init__(self):
|
|
@@ -45,12 +48,12 @@ class DummyClientWithObtain(DummyClient):
|
|
|
45
48
|
def obtain(self, hosts, **kwargs):
|
|
46
49
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
47
50
|
self.obtain_calls.append({"host": host, "kwargs": kwargs})
|
|
48
|
-
return super().
|
|
51
|
+
return super().obtain(hosts, **kwargs)
|
|
49
52
|
|
|
50
53
|
def issue_certificate(self, hosts, **kwargs):
|
|
51
54
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
52
55
|
self.issue_calls.append({"host": host, "kwargs": kwargs})
|
|
53
|
-
return super().
|
|
56
|
+
return super().obtain(hosts, **kwargs)
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
class DummyRemoteClient(CertManagerClient):
|
|
@@ -155,7 +158,7 @@ def test_default_threshold_and_min_floor(monkeypatch):
|
|
|
155
158
|
def test_sleep_computation_slack_and_cap():
|
|
156
159
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
157
160
|
mgr = RenewalManager(DummyClient(), renew_threshold_days=30)
|
|
158
|
-
mgr.
|
|
161
|
+
mgr.update_watch_domains(["example.com"])
|
|
159
162
|
|
|
160
163
|
with mgr._lock:
|
|
161
164
|
mgr._cache["example.com"] = now + timedelta(days=60)
|
|
@@ -184,8 +187,8 @@ def test_existing_cert_failure_sets_threshold_plus_24h_retry():
|
|
|
184
187
|
renew_threshold_days=30,
|
|
185
188
|
clock_fn=lambda: clock["now"],
|
|
186
189
|
)
|
|
187
|
-
mgr.set_watch_domains(["example.com"])
|
|
188
190
|
with mgr._lock:
|
|
191
|
+
mgr._watch_domains = {"example.com"}
|
|
189
192
|
mgr._cache["example.com"] = now - timedelta(hours=1)
|
|
190
193
|
|
|
191
194
|
mgr.trigger_now()
|
|
@@ -219,7 +222,7 @@ def test_existing_cert_failure_does_not_selfsign():
|
|
|
219
222
|
client.set_handler("existing.example.com", RuntimeError("renew failed"))
|
|
220
223
|
|
|
221
224
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
222
|
-
mgr.
|
|
225
|
+
mgr.update_watch_domains(["existing.example.com"])
|
|
223
226
|
with mgr._lock:
|
|
224
227
|
mgr._cache["existing.example.com"] = now - timedelta(hours=1)
|
|
225
228
|
|
|
@@ -252,9 +255,7 @@ def test_expired_local_cert_failure_keeps_existing_cert_and_defers_retry():
|
|
|
252
255
|
client.set_handler("expired.example.com", RuntimeError("renew failed"))
|
|
253
256
|
|
|
254
257
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
255
|
-
mgr.
|
|
256
|
-
|
|
257
|
-
mgr.trigger_now()
|
|
258
|
+
mgr.update_watch_domains(["expired.example.com"])
|
|
258
259
|
|
|
259
260
|
expected = now + timedelta(seconds=mgr.update_threshold_secs + mgr.renew_retry_interval_seconds)
|
|
260
261
|
with mgr._lock:
|
|
@@ -290,8 +291,7 @@ def test_expired_sqlite_cert_failure_does_not_seed_selfsigned_as_fresh():
|
|
|
290
291
|
client.set_handler("sqlite-expired.example.com", RuntimeError("renew failed"))
|
|
291
292
|
|
|
292
293
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
293
|
-
mgr.
|
|
294
|
-
mgr.trigger_now()
|
|
294
|
+
mgr.update_watch_domains(["sqlite-expired.example.com"])
|
|
295
295
|
|
|
296
296
|
with mgr._lock:
|
|
297
297
|
assert "sqlite-expired.example.com" in mgr._cache
|
|
@@ -309,7 +309,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
|
|
|
309
309
|
client.set_handler("retry.example.com", RuntimeError("renew failed"))
|
|
310
310
|
|
|
311
311
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: clock["now"])
|
|
312
|
-
mgr.
|
|
312
|
+
mgr.update_watch_domains(["retry.example.com"])
|
|
313
313
|
with mgr._lock:
|
|
314
314
|
mgr._cache["retry.example.com"] = now - timedelta(minutes=1)
|
|
315
315
|
|
|
@@ -337,15 +337,15 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
|
|
|
337
337
|
mgr = None
|
|
338
338
|
|
|
339
339
|
def sync_watch_domains():
|
|
340
|
-
mgr.
|
|
340
|
+
mgr.update_watch_domains(["c.example.com"])
|
|
341
341
|
|
|
342
342
|
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
343
|
-
mgr.
|
|
343
|
+
mgr.update_watch_domains(["a.example.com", "b.example.com"])
|
|
344
344
|
with mgr._lock:
|
|
345
345
|
mgr._cache["a.example.com"] = now + timedelta(days=20)
|
|
346
346
|
mgr._cache["b.example.com"] = now + timedelta(days=20)
|
|
347
347
|
|
|
348
|
-
mgr.
|
|
348
|
+
mgr.update_watch_domains(["a.example.com"])
|
|
349
349
|
with mgr._lock:
|
|
350
350
|
assert "b.example.com" not in mgr._cache
|
|
351
351
|
|
|
@@ -375,7 +375,7 @@ def test_bootstrap_and_cache_update_from_issued_and_existing():
|
|
|
375
375
|
)
|
|
376
376
|
|
|
377
377
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
378
|
-
mgr.
|
|
378
|
+
mgr.update_watch_domains(["issued.example.com", "existing.example.com"])
|
|
379
379
|
|
|
380
380
|
# No cache initially; both should be obtained immediately to bootstrap.
|
|
381
381
|
mgr._run_cycle(force=False)
|
|
@@ -401,13 +401,39 @@ def test_renewal_manager_prefers_obtain_when_available():
|
|
|
401
401
|
)
|
|
402
402
|
|
|
403
403
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
404
|
-
mgr.
|
|
405
|
-
mgr._run_cycle(force=False)
|
|
404
|
+
mgr.update_watch_domains(["prefer-obtain.example.com"])
|
|
406
405
|
|
|
407
406
|
assert len(client.obtain_calls) == 1
|
|
408
407
|
assert len(client.issue_calls) == 0
|
|
409
408
|
|
|
410
409
|
|
|
410
|
+
def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
|
|
411
|
+
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
412
|
+
client = DummyClient()
|
|
413
|
+
cert = _make_cert_pem("batch.example.com", now, valid_for_days=60)
|
|
414
|
+
client.set_handler(
|
|
415
|
+
"batch.example.com",
|
|
416
|
+
CertificateResponse(issued=[IssuedCert(cert=cert, domains=["batch.example.com"])], existing=[]),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
mgr = RenewalManager(
|
|
420
|
+
client,
|
|
421
|
+
renew_threshold_days=30,
|
|
422
|
+
clock_fn=lambda: now,
|
|
423
|
+
batch_domains=True,
|
|
424
|
+
self_verify=False,
|
|
425
|
+
organization="certapi-tests",
|
|
426
|
+
)
|
|
427
|
+
mgr.update_watch_domains(["batch.example.com"])
|
|
428
|
+
|
|
429
|
+
assert len(client.calls) == 1
|
|
430
|
+
assert client.calls[0]["kwargs"]["batch_domains"] is True
|
|
431
|
+
assert client.calls[0]["kwargs"]["self_verify"] is False
|
|
432
|
+
assert client.calls[0]["kwargs"]["skip_failing"] is False
|
|
433
|
+
assert client.calls[0]["kwargs"]["renew_threshold_days"] == 30
|
|
434
|
+
assert client.calls[0]["kwargs"]["organization"] == "certapi-tests"
|
|
435
|
+
|
|
436
|
+
|
|
411
437
|
def test_local_keystore_seed_prevents_remote_call_when_fresh():
|
|
412
438
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
413
439
|
key = Key.generate("ecdsa")
|
|
@@ -430,8 +456,7 @@ def test_local_keystore_seed_prevents_remote_call_when_fresh():
|
|
|
430
456
|
client.key_store.set_domain_cert("fresh.example.com", [cert])
|
|
431
457
|
|
|
432
458
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
433
|
-
mgr.
|
|
434
|
-
mgr._run_cycle(force=False)
|
|
459
|
+
mgr.update_watch_domains(["fresh.example.com"])
|
|
435
460
|
|
|
436
461
|
with mgr._lock:
|
|
437
462
|
assert "fresh.example.com" in mgr._cache
|
|
@@ -465,8 +490,7 @@ def test_local_keystore_seed_stale_cert_still_renews():
|
|
|
465
490
|
)
|
|
466
491
|
|
|
467
492
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
468
|
-
mgr.
|
|
469
|
-
mgr._run_cycle(force=False)
|
|
493
|
+
mgr.update_watch_domains(["stale.example.com"])
|
|
470
494
|
|
|
471
495
|
assert len(client.calls) == 1
|
|
472
496
|
with mgr._lock:
|
|
@@ -486,9 +510,8 @@ def test_new_domain_failure_selfsigns_and_blacklists():
|
|
|
486
510
|
blacklist_duration_seconds=180,
|
|
487
511
|
clock_fn=lambda: clock["now"],
|
|
488
512
|
)
|
|
489
|
-
mgr.
|
|
513
|
+
mgr.update_watch_domains(["new.example.com"])
|
|
490
514
|
|
|
491
|
-
mgr._run_cycle(force=False)
|
|
492
515
|
assert len(client.calls) == 1
|
|
493
516
|
assert len(client.key_store.saved_certs) == 1
|
|
494
517
|
assert client.key_store.saved_certs[0][2] == "new.example.com.selfsigned"
|
|
@@ -519,19 +542,37 @@ def test_sync_callback_exception_sets_state_error():
|
|
|
519
542
|
assert state["last_error_timestamp"] == now.isoformat()
|
|
520
543
|
|
|
521
544
|
|
|
522
|
-
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()
|
|
523
549
|
client = DummyClient()
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
550
|
+
cert = _make_cert_pem("requested.example.com", now, valid_for_days=90)
|
|
551
|
+
|
|
552
|
+
def slow_handler(host, kwargs):
|
|
553
|
+
started.set()
|
|
554
|
+
release.wait(timeout=2)
|
|
555
|
+
return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
|
|
527
556
|
|
|
528
|
-
|
|
557
|
+
client.set_handler("requested.example.com", slow_handler)
|
|
558
|
+
mgr = RenewalManager(client, clock_fn=lambda: now)
|
|
559
|
+
mgr.start()
|
|
529
560
|
|
|
530
|
-
|
|
531
|
-
|
|
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"
|
|
532
573
|
|
|
533
574
|
|
|
534
|
-
def
|
|
575
|
+
def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
535
576
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
536
577
|
client = DummyClient()
|
|
537
578
|
cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
|
|
@@ -542,7 +583,7 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
|
542
583
|
mgr = None
|
|
543
584
|
|
|
544
585
|
def sync_watch_domains():
|
|
545
|
-
mgr.
|
|
586
|
+
mgr.update_watch_domains(["from-callback.example.com"])
|
|
546
587
|
|
|
547
588
|
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
548
589
|
with mgr._lock:
|
|
@@ -552,9 +593,55 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
|
552
593
|
|
|
553
594
|
with mgr._lock:
|
|
554
595
|
assert mgr._force_trigger is False
|
|
596
|
+
assert mgr._cycle_requested is False
|
|
555
597
|
assert len(client.calls) == 1
|
|
556
598
|
|
|
557
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
|
+
|
|
558
645
|
def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
559
646
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
560
647
|
started = threading.Event()
|
|
@@ -569,7 +656,8 @@ def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
|
569
656
|
|
|
570
657
|
client.set_handler("singleflight.example.com", slow_handler)
|
|
571
658
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
572
|
-
mgr.
|
|
659
|
+
with mgr._lock:
|
|
660
|
+
mgr._watch_domains = {"singleflight.example.com"}
|
|
573
661
|
|
|
574
662
|
first = threading.Thread(target=lambda: mgr.trigger_now())
|
|
575
663
|
first.start()
|
|
@@ -599,7 +687,8 @@ def test_remote_certapi_polling_prints_waiting_message(capsys):
|
|
|
599
687
|
remote_poll_interval_seconds=0.01,
|
|
600
688
|
clock_fn=lambda: now,
|
|
601
689
|
)
|
|
602
|
-
mgr.
|
|
690
|
+
with mgr._lock:
|
|
691
|
+
mgr._watch_domains = {"remote.example.com"}
|
|
603
692
|
|
|
604
693
|
thread = threading.Thread(target=lambda: mgr.trigger_now())
|
|
605
694
|
thread.start()
|
|
@@ -620,9 +709,7 @@ def test_remote_renewal_disables_skip_failing_so_unverified_domain_fails():
|
|
|
620
709
|
client.key_store = DummyKeyStore()
|
|
621
710
|
|
|
622
711
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
623
|
-
mgr.
|
|
624
|
-
|
|
625
|
-
mgr.trigger_now()
|
|
712
|
+
mgr.update_watch_domains(["missing.example.com"])
|
|
626
713
|
|
|
627
714
|
assert len(client.obtain_calls) == 1
|
|
628
715
|
assert client.obtain_calls[0]["kwargs"]["skip_failing"] is False
|
|
@@ -637,7 +724,8 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
|
|
|
637
724
|
release = threading.Event()
|
|
638
725
|
client = DummyRemoteClient(started=started, release=release)
|
|
639
726
|
mgr = RenewalManager(client, remote_poll_interval_seconds=0.01)
|
|
640
|
-
mgr.
|
|
727
|
+
with mgr._lock:
|
|
728
|
+
mgr._watch_domains = {"hung.example.com"}
|
|
641
729
|
|
|
642
730
|
mgr.start()
|
|
643
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.3 → 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.3 → 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
|