certs: async renewal via background task + status polling
- POST /acme/renew returns a request_id and spawns a background task (renew/deploy/refresh steps); dedups per-domain like issue - completes as "skipped" when acme.sh reports the renewal window has not been reached (no --force) - new GET /acme/renew/status endpoint (iface + handler + blueprint) - run acme.sh subprocesses off the event loop (asyncio.to_thread) in both issuance and renewal - ActionCell: busy/busyLabel props; certs page disables the Renew button and polls renewal status with toasts for success/skip/fail
This commit is contained in:
+129
-24
@@ -26,6 +26,7 @@ from daemon.iface import (
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_PATHS,
|
||||
GET_ACME_RENEW_STATUS,
|
||||
POST_ACME_ACCOUNT_REGISTER,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
@@ -174,6 +175,21 @@ def _clean_expired_issuances() -> None:
|
||||
del _ISSUANCES[rid]
|
||||
|
||||
|
||||
def _fail_op(req: IssueRequest, exc: Exception) -> None:
|
||||
"""Record the error on the first running step and mark the request failed."""
|
||||
for step in req.steps:
|
||||
if step.status == "running":
|
||||
step.status = "error"
|
||||
step.message = str(exc)
|
||||
break
|
||||
else:
|
||||
req.steps.append(
|
||||
IssueStep(name="error", label="Error", status="error", message=str(exc))
|
||||
)
|
||||
req.status = "failed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
|
||||
@@ -821,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
if account_email:
|
||||
args.extend(["-m", account_email])
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
# acme.sh is a blocking subprocess — run it off the event loop so
|
||||
# polling, WS broadcasts, and other requests keep responding.
|
||||
output = await asyncio.to_thread(_run_acme, args)
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = output.strip()[:200]
|
||||
|
||||
# Step 2: deploy
|
||||
req.steps[1].status = "running"
|
||||
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
await asyncio.to_thread(
|
||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
||||
)
|
||||
req.steps[1].status = "done"
|
||||
req.steps[1].message = "Deploy hook registered"
|
||||
|
||||
@@ -844,17 +864,7 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
# Mark current running step as error, overall as failed
|
||||
for step in req.steps:
|
||||
if step.status == "running":
|
||||
step.status = "error"
|
||||
step.message = str(exc)
|
||||
break
|
||||
else:
|
||||
req.steps.append(
|
||||
IssueStep(name="error", label="Error", status="error", message=str(exc))
|
||||
)
|
||||
req.status = "failed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
_fail_op(req, exc)
|
||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||
finally:
|
||||
_ISSUANCE_TASKS.pop(req.request_id, None)
|
||||
@@ -862,28 +872,123 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
|
||||
@registry.register(POST_ACME_RENEW)
|
||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/renew — renew a certificate for the given domain.
|
||||
"""POST /acme/renew — start a certificate renewal request (async).
|
||||
|
||||
Deduplicates in-progress requests per domain. Spawns a background task
|
||||
for the actual renewal. When ``force`` is not set, acme.sh skips the
|
||||
renewal if the certificate's renewal window has not been reached yet
|
||||
(the request then completes with status "skipped").
|
||||
|
||||
Args:
|
||||
force: Force renewal regardless of expiry.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the renewal request id (and "existing"
|
||||
status when a renewal for the domain is already running).
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
domain = (body.get("domain") or "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
force = body.get("force", False)
|
||||
args: list[str] = ["--renew", "-d", domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
logger.info("Certificate for %s renewed", domain)
|
||||
refresh_state(["acme"])
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
force = bool(body.get("force", False))
|
||||
|
||||
_clean_expired_issuances()
|
||||
|
||||
# Dedup: if domain already has an active request, return it
|
||||
existing = _find_issuance(domain)
|
||||
if existing:
|
||||
return {
|
||||
"request_id": existing.request_id,
|
||||
"domain": domain,
|
||||
"status": "existing",
|
||||
}
|
||||
|
||||
request_id = uuid4().hex[:12]
|
||||
steps = [
|
||||
IssueStep(name="renew", label="Renewing certificate"),
|
||||
IssueStep(name="deploy", label="Registering deploy hook"),
|
||||
IssueStep(name="refresh", label="Refreshing certificate state"),
|
||||
]
|
||||
req = IssueRequest(request_id=request_id, domain=domain, steps=steps)
|
||||
_ISSUANCES[request_id] = req
|
||||
|
||||
# Spawn background task
|
||||
_task = asyncio.create_task(_run_renew(req, force))
|
||||
_ISSUANCE_TASKS[request_id] = _task
|
||||
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
|
||||
@registry.register(GET_ACME_RENEW_STATUS)
|
||||
def get_renew_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /acme/renew/status — poll status of a renewal request.
|
||||
|
||||
Raises:
|
||||
ValueError: When id is missing.
|
||||
NotFoundError: When request_id is unknown.
|
||||
"""
|
||||
request_id = (body or {}).get("id", "").strip()
|
||||
if not request_id:
|
||||
raise ValueError("'id' is required")
|
||||
|
||||
req = _ISSUANCES.get(request_id)
|
||||
if not req:
|
||||
raise NotFoundError(f"Renewal request {request_id} not found")
|
||||
|
||||
return req.to_dict()
|
||||
|
||||
|
||||
async def _run_renew(req: IssueRequest, force: bool) -> None:
|
||||
"""Background task: renew the certificate, register the deploy hook, refresh state."""
|
||||
try:
|
||||
# Step 1: renew (acme.sh skips when the cert's renewal window has not
|
||||
# been reached unless force is set)
|
||||
req.steps[0].status = "running"
|
||||
args: list[str] = ["--renew", "-d", req.domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
output = await asyncio.to_thread(_run_acme, args)
|
||||
if "Skipping." in output:
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = "Renewal not yet due — skipped"
|
||||
req.status = "skipped"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.info(
|
||||
"Renewal for %s skipped (request %s)", req.domain, req.request_id
|
||||
)
|
||||
return
|
||||
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = output.strip()[:200]
|
||||
|
||||
# Step 2: deploy
|
||||
req.steps[1].status = "running"
|
||||
await asyncio.to_thread(
|
||||
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
|
||||
)
|
||||
req.steps[1].status = "done"
|
||||
req.steps[1].message = "Deploy hook registered"
|
||||
|
||||
# Step 3: refresh state
|
||||
req.steps[2].status = "running"
|
||||
refresh_state(["acme"])
|
||||
req.steps[2].status = "done"
|
||||
req.steps[2].message = "State refreshed"
|
||||
|
||||
req.status = "completed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.info(
|
||||
"Certificate for %s renewed (request %s)", req.domain, req.request_id
|
||||
)
|
||||
except Exception as exc:
|
||||
_fail_op(req, exc)
|
||||
logger.error("Renewal for %s failed: %s", req.domain, exc)
|
||||
finally:
|
||||
_ISSUANCE_TASKS.pop(req.request_id, None)
|
||||
|
||||
|
||||
@registry.register(DELETE_ACME_REMOVE)
|
||||
|
||||
Reference in New Issue
Block a user