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:
+139
-1
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user