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.
Files changed (70) hide show
  1. {certapi-1.1.4/src/certapi.egg-info → certapi-1.1.6}/PKG-INFO +1 -1
  2. {certapi-1.1.4 → certapi-1.1.6}/setup.py +1 -1
  3. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/renewal_manager.py +176 -17
  4. {certapi-1.1.4 → certapi-1.1.6/src/certapi.egg-info}/PKG-INFO +1 -1
  5. {certapi-1.1.4 → certapi-1.1.6}/tests/test_renewal_manager.py +90 -64
  6. {certapi-1.1.4 → certapi-1.1.6}/MANIFEST.in +0 -0
  7. {certapi-1.1.4 → certapi-1.1.6}/README.md +0 -0
  8. {certapi-1.1.4 → certapi-1.1.6}/pyproject.toml +0 -0
  9. {certapi-1.1.4 → certapi-1.1.6}/setup.cfg +0 -0
  10. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/__init__.py +0 -0
  11. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Acme.py +0 -0
  12. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/AcmeError.py +0 -0
  13. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Challenge.py +0 -0
  14. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/Order.py +0 -0
  15. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/__init__.py +0 -0
  16. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/acme/http.py +0 -0
  17. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  18. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  19. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  20. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/__init__.py +0 -0
  21. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  22. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  23. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  24. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  25. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  26. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  27. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  28. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/cli.py +0 -0
  29. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/__init__.py +0 -0
  30. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/client/cert_manager_client.py +0 -0
  31. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/__init__.py +0 -0
  32. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/crypto.py +0 -0
  33. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/crypto/crypto_classes.py +0 -0
  34. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/domain_batching.py +0 -0
  35. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/errors.py +0 -0
  36. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/HttpClientBase.py +0 -0
  37. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/__init__.py +0 -0
  38. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/http/types.py +0 -0
  39. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  40. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  41. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/__init__.py +0 -0
  42. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/issuers/abstract_certissuer.py +0 -0
  43. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  44. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/KeyStore.py +0 -0
  45. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  46. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  47. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  48. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/keystore/__init__.py +0 -0
  49. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/manager/__init__.py +0 -0
  50. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/manager/acme_cert_manager.py +0 -0
  51. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/__init__.py +0 -0
  52. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/api.py +0 -0
  53. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/cert_api.py +0 -0
  54. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/server/key_api.py +0 -0
  55. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/util.py +0 -0
  56. {certapi-1.1.4 → certapi-1.1.6}/src/certapi/utils.py +0 -0
  57. {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/SOURCES.txt +0 -0
  58. {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/dependency_links.txt +0 -0
  59. {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/entry_points.txt +0 -0
  60. {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/requires.txt +0 -0
  61. {certapi-1.1.4 → certapi-1.1.6}/src/certapi.egg-info/top_level.txt +0 -0
  62. {certapi-1.1.4 → certapi-1.1.6}/tests/test_acme_cert_manager_batching.py +0 -0
  63. {certapi-1.1.4 → certapi-1.1.6}/tests/test_acme_error_handling.py +0 -0
  64. {certapi-1.1.4 → certapi-1.1.6}/tests/test_cert_issuer_generic.py +0 -0
  65. {certapi-1.1.4 → certapi-1.1.6}/tests/test_certs_with_key_types.py +0 -0
  66. {certapi-1.1.4 → certapi-1.1.6}/tests/test_cli.py +0 -0
  67. {certapi-1.1.4 → certapi-1.1.6}/tests/test_domain_batching_boulder_cases.py +0 -0
  68. {certapi-1.1.4 → certapi-1.1.6}/tests/test_http_error_handling.py +0 -0
  69. {certapi-1.1.4 → certapi-1.1.6}/tests/test_keystores.py +0 -0
  70. {certapi-1.1.4 → certapi-1.1.6}/tests/test_obtain_interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.4
3
+ Version: 1.1.6
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.4",
5
+ version="1.1.6",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -12,7 +12,19 @@ from certapi.manager.acme_cert_manager import DEFAULT_RENEW_THRESHOLD_DAYS
12
12
 
13
13
  class RenewalManager:
14
14
  """
15
- Background certificate refresh manager.
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
- sync_watch_domains: Optional[Callable[[], None]] = None,
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.sync_watch_domains = sync_watch_domains
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 set_watch_domains(self, domains: List[str]):
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._force_trigger = True
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._sync_watch_domains()
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.4
3
+ Version: 1.1.6
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -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.set_watch_domains(["example.com"])
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.set_watch_domains(["existing.example.com"])
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.set_watch_domains(["expired.example.com"])
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.set_watch_domains(["sqlite-expired.example.com"])
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.set_watch_domains(["retry.example.com"])
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 test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replaces():
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 = None
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.set_watch_domains(["a.example.com"])
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
- # callback replaces watch set with c.example.com before renewal pass.
356
- mgr.trigger_now()
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.set_watch_domains(["issued.example.com", "existing.example.com"])
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.set_watch_domains(["prefer-obtain.example.com"])
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.set_watch_domains(["batch.example.com"])
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.set_watch_domains(["fresh.example.com"])
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.set_watch_domains(["stale.example.com"])
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.set_watch_domains(["new.example.com"])
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 test_sync_callback_exception_sets_state_error():
526
+ def test_update_watch_domains_blocks_until_running_worker_processes_domains():
539
527
  now = datetime(2026, 1, 1, tzinfo=UTC)
540
- mgr = RenewalManager(
541
- DummyClient(),
542
- sync_watch_domains=lambda: (_ for _ in ()).throw(RuntimeError("sync failed")),
543
- clock_fn=lambda: now,
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
- state = mgr.get_state()
549
- assert state["last_error_message"] == "sync failed"
550
- assert state["last_error_timestamp"] == now.isoformat()
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
- def test_set_watch_domains_external_update_requests_followup_cycle_when_running():
554
- client = DummyClient()
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
- mgr.set_watch_domains(["requested.example.com"])
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
- with mgr._lock:
562
- assert mgr._force_trigger is True
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 test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
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 sync_watch_domains():
576
- mgr.set_watch_domains(["from-callback.example.com"])
566
+ def renewal_callback():
567
+ mgr.update_watch_domains(["from-callback.example.com"])
577
568
 
578
- mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
579
- with mgr._lock:
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.set_watch_domains(["singleflight.example.com"])
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.set_watch_domains(["remote.example.com"])
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.set_watch_domains(["missing.example.com"])
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.set_watch_domains(["hung.example.com"])
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