certapi 1.1.4__tar.gz → 1.1.6__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.6}/PKG-INFO +1 -1
- {certapi-1.1.4 → certapi-1.1.6}/setup.py +1 -1
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/renewal_manager.py +176 -17
- {certapi-1.1.4 → certapi-1.1.6/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_renewal_manager.py +90 -64
- {certapi-1.1.4 → certapi-1.1.6}/MANIFEST.in +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/README.md +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/pyproject.toml +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/setup.cfg +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/http.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/cli.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/domain_batching.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/errors.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/types.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/manager/acme_cert_manager.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/util.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi/utils.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/SOURCES.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_acme_cert_manager_batching.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_acme_error_handling.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_cli.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_domain_batching_boulder_cases.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_keystores.py +0 -0
- {certapi-1.1.4 → certapi-1.1.6}/tests/test_obtain_interface.py +0 -0
|
@@ -12,7 +12,19 @@ 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.
|
|
22
|
+
|
|
23
|
+
The integration owns domain discovery. Certapi's background renewal cycle
|
|
24
|
+
only invokes ``renewal_callback`` when it is time to refresh external state;
|
|
25
|
+
that callback is responsible for calling :meth:`update_watch_domains` with
|
|
26
|
+
the current complete domain set. ``update_watch_domains`` is the only method
|
|
27
|
+
that mutates watched domains and performs due renewal work.
|
|
16
28
|
|
|
17
29
|
Algorithm:
|
|
18
30
|
- Maintain a watched domain set and cache each domain's certificate expiry.
|
|
@@ -27,12 +39,20 @@ class RenewalManager:
|
|
|
27
39
|
- On failure for renewal, where a cached or local certificate exists, keep reusing
|
|
28
40
|
that existing certificate even if it is expired, defer the next retry, and do not
|
|
29
41
|
replace it with a self-signed certificate.
|
|
42
|
+
|
|
43
|
+
The manager keeps an in-memory expiry cache, bootstraps that cache from the
|
|
44
|
+
backend keystore when possible, renews missing or soon-expiring
|
|
45
|
+
certificates, and records failures in :meth:`get_state`. New domains that
|
|
46
|
+
fail issuance receive a short-lived retry blacklist and, when the backend
|
|
47
|
+
exposes a keystore, a local self-signed fallback certificate. Renewal
|
|
48
|
+
failures for domains with an existing certificate keep using that
|
|
49
|
+
certificate and schedule a later retry.
|
|
30
50
|
"""
|
|
31
51
|
|
|
32
52
|
def __init__(
|
|
33
53
|
self,
|
|
34
54
|
cert_manager_client,
|
|
35
|
-
|
|
55
|
+
renewal_callback: Optional[Callable[[], None]] = None,
|
|
36
56
|
renew_threshold_days: Optional[int] = None,
|
|
37
57
|
min_renew_threshold_days: int = 10,
|
|
38
58
|
sleep_slack_seconds: int = 300,
|
|
@@ -52,8 +72,51 @@ class RenewalManager:
|
|
|
52
72
|
organization: Optional[str] = None,
|
|
53
73
|
user_id: Optional[str] = None,
|
|
54
74
|
):
|
|
75
|
+
"""
|
|
76
|
+
Create a renewal manager for a certificate backend.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
cert_manager_client: Backend object with an ``obtain(domains,
|
|
80
|
+
**kwargs)`` method. Local managers and ``CertManagerClient`` are
|
|
81
|
+
both supported.
|
|
82
|
+
renewal_callback: Optional callback invoked by background or forced
|
|
83
|
+
renewal cycles. Integrations should use this to recompute their
|
|
84
|
+
domain list and call :meth:`update_watch_domains`. The callback
|
|
85
|
+
itself should not perform certificate issuance directly.
|
|
86
|
+
renew_threshold_days: Renew certificates when they expire within
|
|
87
|
+
this many days. Defaults to ``CERT_RENEW_THRESHOLD_DAYS`` or the
|
|
88
|
+
certapi default.
|
|
89
|
+
min_renew_threshold_days: Lower bound for renewal attempts, used to
|
|
90
|
+
avoid renewing too close to expiry even when a smaller threshold
|
|
91
|
+
is configured.
|
|
92
|
+
sleep_slack_seconds: Extra delay added after the calculated next
|
|
93
|
+
renewal time to avoid waking exactly on the threshold boundary.
|
|
94
|
+
max_sleep_seconds: Maximum time the background worker sleeps between
|
|
95
|
+
checks.
|
|
96
|
+
renew_retry_interval_seconds: Delay before retrying renewal for a
|
|
97
|
+
domain that already has a local certificate.
|
|
98
|
+
blacklist_duration_seconds: Delay before retrying issuance for a
|
|
99
|
+
domain that had no usable local certificate.
|
|
100
|
+
remote_poll_interval_seconds: Poll interval while waiting for remote
|
|
101
|
+
``CertManagerClient`` requests.
|
|
102
|
+
clock_fn: Optional clock override for deterministic tests.
|
|
103
|
+
sleep_fn: Optional sleep override for deterministic tests.
|
|
104
|
+
key_type: Private key type requested from the backend.
|
|
105
|
+
expiry_days: Requested certificate lifetime when the backend
|
|
106
|
+
supports it.
|
|
107
|
+
batch_domains: Forwarded to the backend to enable domain batching.
|
|
108
|
+
self_verify: Forwarded to the backend to enable or disable
|
|
109
|
+
ownership self-verification.
|
|
110
|
+
country: Optional subject country for issued or fallback certs.
|
|
111
|
+
state: Optional subject state for issued or fallback certs.
|
|
112
|
+
locality: Optional subject locality for issued or fallback certs.
|
|
113
|
+
organization: Optional subject organization for issued or fallback
|
|
114
|
+
certs.
|
|
115
|
+
user_id: Optional subject/user identifier for issued or fallback
|
|
116
|
+
certs.
|
|
117
|
+
"""
|
|
55
118
|
self.cert_manager_client = cert_manager_client
|
|
56
|
-
self.
|
|
119
|
+
self.renewal_callback = renewal_callback
|
|
57
120
|
self.key_type = key_type
|
|
58
121
|
self.expiry_days = expiry_days
|
|
59
122
|
self.batch_domains = batch_domains
|
|
@@ -89,34 +152,67 @@ class RenewalManager:
|
|
|
89
152
|
self._lock = threading.Condition()
|
|
90
153
|
self._thread: Optional[threading.Thread] = None
|
|
91
154
|
self._running = False
|
|
155
|
+
self._cycle_requested = False
|
|
92
156
|
self._force_trigger = False
|
|
93
157
|
self._cycle_running = False
|
|
94
158
|
self._cycle_thread_id: Optional[int] = None
|
|
159
|
+
self._cycle_generation = 0
|
|
160
|
+
self._renewal_running = False
|
|
161
|
+
self._renewal_requested = False
|
|
162
|
+
self._renewal_thread_id: Optional[int] = None
|
|
163
|
+
self._renewal_generation = 0
|
|
95
164
|
self._blacklist: Dict[str, datetime] = {}
|
|
96
165
|
self._last_error_message: Optional[str] = None
|
|
97
166
|
self._last_error_timestamp: Optional[datetime] = None
|
|
98
167
|
self._self_signer: Optional[SelfCertIssuer] = None
|
|
99
168
|
|
|
100
|
-
def
|
|
169
|
+
def update_watch_domains(self, domains: List[str]):
|
|
170
|
+
"""
|
|
171
|
+
Replace the complete set of domains and process it before returning.
|
|
172
|
+
|
|
173
|
+
Empty values are ignored. Cache and blacklist entries for domains no
|
|
174
|
+
longer being watched are dropped.
|
|
175
|
+
|
|
176
|
+
This method immediately runs or queues a renewal pass and blocks until
|
|
177
|
+
certapi has attempted any due obtain/renew work for the latest published
|
|
178
|
+
set. Concurrent calls are serialized inside the manager; if a renewal
|
|
179
|
+
pass is already running, the current pass is allowed to finish and a
|
|
180
|
+
follow-up pass processes the newest watch set before callers return.
|
|
181
|
+
"""
|
|
101
182
|
new_watch_set = {x for x in domains if x}
|
|
102
183
|
with self._lock:
|
|
103
184
|
self._watch_domains = new_watch_set
|
|
104
185
|
self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
|
|
105
186
|
self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
|
|
106
|
-
if self._running and self._cycle_thread_id != threading.get_ident():
|
|
107
|
-
self._force_trigger = True
|
|
108
187
|
self._lock.notify_all()
|
|
109
188
|
|
|
189
|
+
self._run_renewal_pass(force=False)
|
|
190
|
+
|
|
110
191
|
def start(self):
|
|
192
|
+
"""
|
|
193
|
+
Start the background renewal worker.
|
|
194
|
+
|
|
195
|
+
The worker immediately performs a forced renewal pass, then sleeps until
|
|
196
|
+
the next watched certificate approaches the renewal threshold or until
|
|
197
|
+
another thread publishes a new set with :meth:`update_watch_domains`.
|
|
198
|
+
Calling ``start`` while already running is a no-op.
|
|
199
|
+
"""
|
|
111
200
|
with self._lock:
|
|
112
201
|
if self._running:
|
|
113
202
|
return
|
|
114
203
|
self._running = True
|
|
115
|
-
self.
|
|
204
|
+
self._cycle_requested = True
|
|
116
205
|
self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
|
|
117
206
|
self._thread.start()
|
|
118
207
|
|
|
119
208
|
def stop(self):
|
|
209
|
+
"""
|
|
210
|
+
Stop the background renewal worker and wait briefly for it to exit.
|
|
211
|
+
|
|
212
|
+
In-flight remote certificate requests run in daemon helper threads and
|
|
213
|
+
are not waited on indefinitely; the manager stops scheduling new work
|
|
214
|
+
and joins the worker thread for up to two seconds.
|
|
215
|
+
"""
|
|
120
216
|
thread = None
|
|
121
217
|
with self._lock:
|
|
122
218
|
self._running = False
|
|
@@ -127,14 +223,41 @@ class RenewalManager:
|
|
|
127
223
|
thread.join(timeout=2)
|
|
128
224
|
|
|
129
225
|
def trigger_now(self):
|
|
226
|
+
"""
|
|
227
|
+
Run the renewal trigger immediately.
|
|
228
|
+
|
|
229
|
+
When a renewal callback is configured, this method invokes that callback
|
|
230
|
+
and waits for it to return. The callback should call
|
|
231
|
+
:meth:`update_watch_domains`, which performs due renewal work. Without a
|
|
232
|
+
callback, this method performs a direct forced renewal pass over the
|
|
233
|
+
existing watch set.
|
|
234
|
+
|
|
235
|
+
If called from inside the renewal cycle itself, the method only requests
|
|
236
|
+
a follow-up pass and returns immediately to avoid deadlock.
|
|
237
|
+
"""
|
|
130
238
|
with self._lock:
|
|
131
239
|
if self._running:
|
|
240
|
+
if self._cycle_thread_id == threading.get_ident():
|
|
241
|
+
self._force_trigger = True
|
|
242
|
+
self._lock.notify_all()
|
|
243
|
+
return
|
|
244
|
+
target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
|
|
132
245
|
self._force_trigger = True
|
|
133
246
|
self._lock.notify_all()
|
|
247
|
+
while self._running and self._cycle_generation < target_generation:
|
|
248
|
+
self._lock.wait()
|
|
134
249
|
return
|
|
135
250
|
self._run_cycle(force=True)
|
|
136
251
|
|
|
137
252
|
def get_state(self) -> Dict[str, Any]:
|
|
253
|
+
"""
|
|
254
|
+
Return a snapshot of renewal-manager state for diagnostics.
|
|
255
|
+
|
|
256
|
+
The returned dictionary includes watched domains, cache size, active
|
|
257
|
+
blacklisted domains, next cached expiry time, whether the background
|
|
258
|
+
worker is running, and the most recent error message/timestamp recorded
|
|
259
|
+
by the manager.
|
|
260
|
+
"""
|
|
138
261
|
with self._lock:
|
|
139
262
|
next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
|
|
140
263
|
return {
|
|
@@ -157,6 +280,7 @@ class RenewalManager:
|
|
|
157
280
|
if not self._running:
|
|
158
281
|
return
|
|
159
282
|
force = self._force_trigger
|
|
283
|
+
self._cycle_requested = False
|
|
160
284
|
self._force_trigger = False
|
|
161
285
|
|
|
162
286
|
attempt_count = self._run_cycle(force=force)
|
|
@@ -164,7 +288,7 @@ class RenewalManager:
|
|
|
164
288
|
with self._lock:
|
|
165
289
|
if not self._running:
|
|
166
290
|
return
|
|
167
|
-
if self._force_trigger:
|
|
291
|
+
if self._force_trigger or self._cycle_requested:
|
|
168
292
|
continue
|
|
169
293
|
wait_seconds = self._compute_wait_seconds(self.clock_fn())
|
|
170
294
|
|
|
@@ -192,14 +316,6 @@ class RenewalManager:
|
|
|
192
316
|
# Testing hook: allows deterministic no-op sleep behavior.
|
|
193
317
|
self.sleep_fn(wait_seconds)
|
|
194
318
|
|
|
195
|
-
def _sync_watch_domains(self):
|
|
196
|
-
if self.sync_watch_domains is None:
|
|
197
|
-
return
|
|
198
|
-
try:
|
|
199
|
-
self.sync_watch_domains()
|
|
200
|
-
except Exception as e:
|
|
201
|
-
self._set_error(e)
|
|
202
|
-
|
|
203
319
|
def _due_window_secs(self) -> float:
|
|
204
320
|
return max(self.update_threshold_secs, self.cert_min_renew_threshold_secs)
|
|
205
321
|
|
|
@@ -233,10 +349,53 @@ class RenewalManager:
|
|
|
233
349
|
with self._lock:
|
|
234
350
|
self._cycle_running = False
|
|
235
351
|
self._cycle_thread_id = None
|
|
352
|
+
self._cycle_generation += 1
|
|
236
353
|
self._lock.notify_all()
|
|
237
354
|
|
|
238
355
|
def _run_cycle_body(self, force: bool = False) -> int:
|
|
239
|
-
self.
|
|
356
|
+
if self.renewal_callback is None:
|
|
357
|
+
return self._run_renewal_pass(force=force)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
self.renewal_callback()
|
|
361
|
+
except Exception as e:
|
|
362
|
+
self._set_error(e)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
def _run_renewal_pass(self, force: bool = False) -> int:
|
|
366
|
+
count = 0
|
|
367
|
+
current_thread_id = threading.get_ident()
|
|
368
|
+
with self._lock:
|
|
369
|
+
if self._renewal_thread_id == current_thread_id:
|
|
370
|
+
return 0
|
|
371
|
+
if self._renewal_running:
|
|
372
|
+
target_generation = self._renewal_generation + 2
|
|
373
|
+
self._renewal_requested = True
|
|
374
|
+
self._lock.notify_all()
|
|
375
|
+
while self._renewal_generation < target_generation:
|
|
376
|
+
self._lock.wait()
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
self._renewal_running = True
|
|
380
|
+
self._renewal_thread_id = current_thread_id
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
while True:
|
|
384
|
+
count += self._run_renewal_pass_body(force=force)
|
|
385
|
+
with self._lock:
|
|
386
|
+
self._renewal_generation += 1
|
|
387
|
+
if not self._renewal_requested:
|
|
388
|
+
return count
|
|
389
|
+
self._renewal_requested = False
|
|
390
|
+
force = False
|
|
391
|
+
self._lock.notify_all()
|
|
392
|
+
finally:
|
|
393
|
+
with self._lock:
|
|
394
|
+
self._renewal_running = False
|
|
395
|
+
self._renewal_thread_id = None
|
|
396
|
+
self._lock.notify_all()
|
|
397
|
+
|
|
398
|
+
def _run_renewal_pass_body(self, force: bool = False) -> int:
|
|
240
399
|
self._seed_cache_from_local_keystore()
|
|
241
400
|
now = self.clock_fn()
|
|
242
401
|
self._clean_blacklist(now)
|
|
@@ -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
|
|
|
@@ -328,7 +325,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
|
|
|
328
325
|
assert len(client.calls) == 1
|
|
329
326
|
|
|
330
327
|
|
|
331
|
-
def
|
|
328
|
+
def test_watch_domain_replacement_drops_unwatched_cache_and_external_update_processes_new_set():
|
|
332
329
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
333
330
|
client = DummyClient()
|
|
334
331
|
cert_c = _make_cert_pem("c.example.com", now, valid_for_days=60)
|
|
@@ -337,23 +334,19 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
|
|
|
337
334
|
CertificateResponse(issued=[IssuedCert(cert=cert_c, domains=["c.example.com"])], existing=[]),
|
|
338
335
|
)
|
|
339
336
|
|
|
340
|
-
mgr =
|
|
341
|
-
|
|
342
|
-
def sync_watch_domains():
|
|
343
|
-
mgr.set_watch_domains(["c.example.com"])
|
|
344
|
-
|
|
345
|
-
mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
|
|
346
|
-
mgr.set_watch_domains(["a.example.com", "b.example.com"])
|
|
337
|
+
mgr = RenewalManager(client, clock_fn=lambda: now)
|
|
338
|
+
mgr.update_watch_domains(["a.example.com", "b.example.com"])
|
|
347
339
|
with mgr._lock:
|
|
348
340
|
mgr._cache["a.example.com"] = now + timedelta(days=20)
|
|
349
341
|
mgr._cache["b.example.com"] = now + timedelta(days=20)
|
|
350
342
|
|
|
351
|
-
mgr.
|
|
343
|
+
mgr.update_watch_domains(["a.example.com"])
|
|
352
344
|
with mgr._lock:
|
|
353
345
|
assert "b.example.com" not in mgr._cache
|
|
354
346
|
|
|
355
|
-
#
|
|
356
|
-
|
|
347
|
+
# Integrations can publish new domain state directly, outside the timed
|
|
348
|
+
# renewal callback path.
|
|
349
|
+
mgr.update_watch_domains(["c.example.com"])
|
|
357
350
|
state = mgr.get_state()
|
|
358
351
|
assert state["watched_domains"] == ["c.example.com"]
|
|
359
352
|
with mgr._lock:
|
|
@@ -378,7 +371,7 @@ def test_bootstrap_and_cache_update_from_issued_and_existing():
|
|
|
378
371
|
)
|
|
379
372
|
|
|
380
373
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
381
|
-
mgr.
|
|
374
|
+
mgr.update_watch_domains(["issued.example.com", "existing.example.com"])
|
|
382
375
|
|
|
383
376
|
# No cache initially; both should be obtained immediately to bootstrap.
|
|
384
377
|
mgr._run_cycle(force=False)
|
|
@@ -404,8 +397,7 @@ def test_renewal_manager_prefers_obtain_when_available():
|
|
|
404
397
|
)
|
|
405
398
|
|
|
406
399
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
407
|
-
mgr.
|
|
408
|
-
mgr._run_cycle(force=False)
|
|
400
|
+
mgr.update_watch_domains(["prefer-obtain.example.com"])
|
|
409
401
|
|
|
410
402
|
assert len(client.obtain_calls) == 1
|
|
411
403
|
assert len(client.issue_calls) == 0
|
|
@@ -428,8 +420,7 @@ def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
|
|
|
428
420
|
self_verify=False,
|
|
429
421
|
organization="certapi-tests",
|
|
430
422
|
)
|
|
431
|
-
mgr.
|
|
432
|
-
mgr.trigger_now()
|
|
423
|
+
mgr.update_watch_domains(["batch.example.com"])
|
|
433
424
|
|
|
434
425
|
assert len(client.calls) == 1
|
|
435
426
|
assert client.calls[0]["kwargs"]["batch_domains"] is True
|
|
@@ -461,8 +452,7 @@ def test_local_keystore_seed_prevents_remote_call_when_fresh():
|
|
|
461
452
|
client.key_store.set_domain_cert("fresh.example.com", [cert])
|
|
462
453
|
|
|
463
454
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
464
|
-
mgr.
|
|
465
|
-
mgr._run_cycle(force=False)
|
|
455
|
+
mgr.update_watch_domains(["fresh.example.com"])
|
|
466
456
|
|
|
467
457
|
with mgr._lock:
|
|
468
458
|
assert "fresh.example.com" in mgr._cache
|
|
@@ -496,8 +486,7 @@ def test_local_keystore_seed_stale_cert_still_renews():
|
|
|
496
486
|
)
|
|
497
487
|
|
|
498
488
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
499
|
-
mgr.
|
|
500
|
-
mgr._run_cycle(force=False)
|
|
489
|
+
mgr.update_watch_domains(["stale.example.com"])
|
|
501
490
|
|
|
502
491
|
assert len(client.calls) == 1
|
|
503
492
|
with mgr._lock:
|
|
@@ -517,9 +506,8 @@ def test_new_domain_failure_selfsigns_and_blacklists():
|
|
|
517
506
|
blacklist_duration_seconds=180,
|
|
518
507
|
clock_fn=lambda: clock["now"],
|
|
519
508
|
)
|
|
520
|
-
mgr.
|
|
509
|
+
mgr.update_watch_domains(["new.example.com"])
|
|
521
510
|
|
|
522
|
-
mgr._run_cycle(force=False)
|
|
523
511
|
assert len(client.calls) == 1
|
|
524
512
|
assert len(client.key_store.saved_certs) == 1
|
|
525
513
|
assert client.key_store.saved_certs[0][2] == "new.example.com.selfsigned"
|
|
@@ -535,34 +523,37 @@ def test_new_domain_failure_selfsigns_and_blacklists():
|
|
|
535
523
|
assert len(client.calls) == 2
|
|
536
524
|
|
|
537
525
|
|
|
538
|
-
def
|
|
526
|
+
def test_update_watch_domains_blocks_until_running_worker_processes_domains():
|
|
539
527
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
545
|
-
|
|
546
|
-
mgr.trigger_now()
|
|
528
|
+
started = threading.Event()
|
|
529
|
+
release = threading.Event()
|
|
530
|
+
client = DummyClient()
|
|
531
|
+
cert = _make_cert_pem("requested.example.com", now, valid_for_days=90)
|
|
547
532
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
533
|
+
def slow_handler(host, kwargs):
|
|
534
|
+
started.set()
|
|
535
|
+
release.wait(timeout=2)
|
|
536
|
+
return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
|
|
551
537
|
|
|
538
|
+
client.set_handler("requested.example.com", slow_handler)
|
|
539
|
+
mgr = RenewalManager(client, clock_fn=lambda: now)
|
|
540
|
+
mgr.start()
|
|
552
541
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
mgr = RenewalManager(client)
|
|
556
|
-
with mgr._lock:
|
|
557
|
-
mgr._running = True
|
|
542
|
+
update_thread = threading.Thread(target=lambda: mgr.update_watch_domains(["requested.example.com"]))
|
|
543
|
+
update_thread.start()
|
|
558
544
|
|
|
559
|
-
|
|
545
|
+
assert started.wait(timeout=2)
|
|
546
|
+
assert update_thread.is_alive()
|
|
547
|
+
release.set()
|
|
548
|
+
update_thread.join(timeout=2)
|
|
549
|
+
mgr.stop()
|
|
560
550
|
|
|
561
|
-
|
|
562
|
-
|
|
551
|
+
assert not update_thread.is_alive()
|
|
552
|
+
assert len(client.calls) == 1
|
|
553
|
+
assert client.calls[0]["host"] == "requested.example.com"
|
|
563
554
|
|
|
564
555
|
|
|
565
|
-
def
|
|
556
|
+
def test_renewal_cycle_invokes_callback_and_callback_updates_domains():
|
|
566
557
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
567
558
|
client = DummyClient()
|
|
568
559
|
cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
|
|
@@ -572,18 +563,52 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
|
|
|
572
563
|
)
|
|
573
564
|
mgr = None
|
|
574
565
|
|
|
575
|
-
def
|
|
576
|
-
mgr.
|
|
566
|
+
def renewal_callback():
|
|
567
|
+
mgr.update_watch_domains(["from-callback.example.com"])
|
|
577
568
|
|
|
578
|
-
mgr = RenewalManager(client,
|
|
579
|
-
|
|
580
|
-
mgr._running = True
|
|
581
|
-
|
|
582
|
-
mgr._run_cycle(force=False)
|
|
569
|
+
mgr = RenewalManager(client, renewal_callback=renewal_callback, clock_fn=lambda: now)
|
|
570
|
+
mgr.trigger_now()
|
|
583
571
|
|
|
584
572
|
with mgr._lock:
|
|
573
|
+
assert mgr._watch_domains == {"from-callback.example.com"}
|
|
574
|
+
assert "from-callback.example.com" in mgr._cache
|
|
585
575
|
assert mgr._force_trigger is False
|
|
576
|
+
assert mgr._cycle_requested is False
|
|
577
|
+
assert len(client.calls) == 1
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def test_running_trigger_now_blocks_until_callback_update_finishes():
|
|
581
|
+
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
582
|
+
obtain_started = threading.Event()
|
|
583
|
+
release_obtain = threading.Event()
|
|
584
|
+
client = DummyClient()
|
|
585
|
+
cert = _make_cert_pem("blocking.example.com", now, valid_for_days=90)
|
|
586
|
+
mgr = None
|
|
587
|
+
|
|
588
|
+
def slow_handler(host, kwargs):
|
|
589
|
+
obtain_started.set()
|
|
590
|
+
release_obtain.wait(timeout=2)
|
|
591
|
+
return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
|
|
592
|
+
|
|
593
|
+
def renewal_callback():
|
|
594
|
+
mgr.update_watch_domains(["blocking.example.com"])
|
|
595
|
+
|
|
596
|
+
client.set_handler("blocking.example.com", slow_handler)
|
|
597
|
+
mgr = RenewalManager(client, renewal_callback=renewal_callback, clock_fn=lambda: now)
|
|
598
|
+
|
|
599
|
+
mgr.start()
|
|
600
|
+
trigger_thread = threading.Thread(target=mgr.trigger_now)
|
|
601
|
+
trigger_thread.start()
|
|
602
|
+
|
|
603
|
+
assert obtain_started.wait(timeout=2)
|
|
604
|
+
assert trigger_thread.is_alive()
|
|
605
|
+
release_obtain.set()
|
|
606
|
+
trigger_thread.join(timeout=2)
|
|
607
|
+
|
|
608
|
+
mgr.stop()
|
|
609
|
+
assert not trigger_thread.is_alive()
|
|
586
610
|
assert len(client.calls) == 1
|
|
611
|
+
assert client.calls[0]["host"] == "blocking.example.com"
|
|
587
612
|
|
|
588
613
|
|
|
589
614
|
def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
@@ -600,7 +625,8 @@ def test_singleflight_suppresses_concurrent_manual_cycles():
|
|
|
600
625
|
|
|
601
626
|
client.set_handler("singleflight.example.com", slow_handler)
|
|
602
627
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
603
|
-
mgr.
|
|
628
|
+
with mgr._lock:
|
|
629
|
+
mgr._watch_domains = {"singleflight.example.com"}
|
|
604
630
|
|
|
605
631
|
first = threading.Thread(target=lambda: mgr.trigger_now())
|
|
606
632
|
first.start()
|
|
@@ -630,7 +656,8 @@ def test_remote_certapi_polling_prints_waiting_message(capsys):
|
|
|
630
656
|
remote_poll_interval_seconds=0.01,
|
|
631
657
|
clock_fn=lambda: now,
|
|
632
658
|
)
|
|
633
|
-
mgr.
|
|
659
|
+
with mgr._lock:
|
|
660
|
+
mgr._watch_domains = {"remote.example.com"}
|
|
634
661
|
|
|
635
662
|
thread = threading.Thread(target=lambda: mgr.trigger_now())
|
|
636
663
|
thread.start()
|
|
@@ -651,9 +678,7 @@ def test_remote_renewal_disables_skip_failing_so_unverified_domain_fails():
|
|
|
651
678
|
client.key_store = DummyKeyStore()
|
|
652
679
|
|
|
653
680
|
mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
|
|
654
|
-
mgr.
|
|
655
|
-
|
|
656
|
-
mgr.trigger_now()
|
|
681
|
+
mgr.update_watch_domains(["missing.example.com"])
|
|
657
682
|
|
|
658
683
|
assert len(client.obtain_calls) == 1
|
|
659
684
|
assert client.obtain_calls[0]["kwargs"]["skip_failing"] is False
|
|
@@ -668,7 +693,8 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
|
|
|
668
693
|
release = threading.Event()
|
|
669
694
|
client = DummyRemoteClient(started=started, release=release)
|
|
670
695
|
mgr = RenewalManager(client, remote_poll_interval_seconds=0.01)
|
|
671
|
-
mgr.
|
|
696
|
+
with mgr._lock:
|
|
697
|
+
mgr._watch_domains = {"hung.example.com"}
|
|
672
698
|
|
|
673
699
|
mgr.start()
|
|
674
700
|
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.6}/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.6}/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
|