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
+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"]