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:
2026-08-20 13:10:16 +00:00
parent 332d14e37d
commit 94705490b9
9 changed files with 462 additions and 44 deletions
+58
View File
@@ -530,6 +530,64 @@ class TestCertsIssue:
assert resp.status_code == 400
class TestCertsRenew:
@_ce("post")
def test_start_renew(self, mock_post, client):
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["request_id"] == "abc123"
args = mock_post.call_args.args
assert args[0] == ("POST", "/acme/renew")
assert args[1] == {"domain": "example.com"}
@_ce("post")
def test_start_renew_rejected(self, mock_post, client):
from daemon.client import BadRequest
mock_post.side_effect = BadRequest("'domain' is required")
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 400
assert resp.get_json()["ok"] is False
@_ce("post")
def test_start_renew_runtime_error(self, mock_post, client):
mock_post.side_effect = RuntimeError("daemon unreachable")
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
class TestCertsRenewStatus:
@_ce("get")
def test_success(self, mock_get, client):
mock_get.return_value = {
"request_id": "abc123",
"domain": "example.com",
"status": "completed",
"steps": [],
}
resp = client.get("/api/certs/renew/abc123")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["status"] == "completed"
args = mock_get.call_args.args
assert args[0] == ("GET", "/acme/renew/status")
assert args[1] == {"id": "abc123"}
@_ce("get")
def test_not_found(self, mock_get, client):
from daemon.client import NotFound
mock_get.side_effect = NotFound("renewal request not found")
resp = client.get("/api/certs/renew/unknown")
assert resp.status_code == 404
assert resp.get_json()["ok"] is False
class TestCertsEmail:
def test_missing_email(self, client):
resp = client.post("/api/certs/email", json={})
+139 -1
View File
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
import pytest
import daemon.handlers.acme as acme_mod
from daemon.handlers.acme import (
IssueRequest,
_check_account_registered,
_check_acme_account,
_check_acme_home_writable,
@@ -25,10 +27,25 @@ from daemon.handlers.acme import (
deactivate_account,
generate_self_signed,
get_account,
get_renew_status,
issue_cert,
register_account,
renew_cert,
)
from daemon.server import ConflictError
from daemon.server import ConflictError, NotFoundError
def _await_renew(body):
"""Start a renewal via renew_cert and await its background task."""
async def _run():
result = renew_cert(None, body)
task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id"))
if task is not None:
await task
return result
return asyncio.run(_run())
class TestGenerateSelfSigned:
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
assert result["domain"] == "example.com"
assert "request_id" in result
mock_run_issue.assert_called_once()
class TestRenewCert:
def test_requires_body(self):
with pytest.raises(ValueError, match="Request body required"):
renew_cert(None, None)
def test_requires_domain(self):
with pytest.raises(ValueError, match="'domain' is required"):
renew_cert(None, {"force": True})
def test_starts_background_task(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch(
"daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'"
),
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert result["domain"] == "example.com"
assert "request_id" in result
assert req.status == "completed"
assert [s.status for s in req.steps] == ["done", "done", "done"]
mock_refresh.assert_called_once_with(["acme"])
def test_skip_when_not_due(self):
output = (
"[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. "
"Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)"
)
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value=output),
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert req.status == "skipped"
assert req.steps[0].status == "done"
mock_refresh.assert_not_called()
def test_failure_marks_failed(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch(
"daemon.handlers.acme._run_acme",
side_effect=RuntimeError("acme.sh failed with exit code 1: boom"),
),
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert req.status == "failed"
assert req.steps[0].status == "error"
assert "boom" in req.steps[0].message
def test_force_appends_flag(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme,
patch("daemon.handlers.acme.refresh_state"),
):
_await_renew({"domain": "example.com", "force": True})
renew_args = mock_acme.call_args_list[0].args[0]
assert "--force" in renew_args
def test_dedup_running_returns_existing(self):
existing = IssueRequest(request_id="existing123", domain="example.com")
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme") as mock_acme,
):
acme_mod._ISSUANCES["existing123"] = existing
result = renew_cert(None, {"domain": "example.com"})
assert result == {
"request_id": "existing123",
"domain": "example.com",
"status": "existing",
}
mock_acme.assert_not_called()
class TestGetRenewStatus:
def test_missing_id(self):
with pytest.raises(ValueError, match="'id' is required"):
get_renew_status(None, None)
with pytest.raises(ValueError, match="'id' is required"):
get_renew_status(None, {})
def test_unknown_id(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
pytest.raises(NotFoundError, match="not found"),
):
get_renew_status(None, {"id": "unknown"})
def test_returns_request_dict(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value="Renewed"),
patch("daemon.handlers.acme.refresh_state"),
):
result = _await_renew({"domain": "example.com"})
status = get_renew_status(None, {"id": result["request_id"]})
assert status["request_id"] == result["request_id"]
assert status["domain"] == "example.com"
assert status["status"] == "completed"
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]