feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)

- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
2026-05-30 05:45:40 +00:00
parent c091063248
commit dc96e15643
19 changed files with 1960 additions and 986 deletions
+35 -83
View File
@@ -1,7 +1,7 @@
"""aiohttp server for vacuum-walld.
Listens on a Unix socket, serves the daemon API to the web UI.
Handles routing, caching, batching, and request/response lifecycle.
Handles routing, batching, and request/response lifecycle.
"""
import asyncio
@@ -15,60 +15,20 @@ from typing import Any
from aiohttp import web
from lib.state import state as state_store
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
class Cache:
"""Tag-based cache. Entries persist until invalidated by write operations.
External changes to system state (e.g., manual firewall-cmd, config edits on
disk) bypass cache invalidation and will result in stale data until the cache
is cleared or affected tags are invalidated.
"""
def __init__(self) -> None:
self._store: dict[str, Any] = {}
self._tags: dict[str, set[str]] = {}
def get(self, key: str) -> Any | None:
return self._store.get(key)
def set(self, key: str, value: Any, tags: set[str]) -> None:
self._store[key] = value
self._tags[key] = tags
def invalidate(self, *tags: str) -> None:
for tag in tags:
keys = [k for k, ts in self._tags.items() if tag in ts]
for k in keys:
self._store.pop(k, None)
self._tags.pop(k, None)
def clear(self) -> None:
self._store.clear()
self._tags.clear()
cache = Cache()
class Handler:
"""Wrapper for a daemon handler function."""
def __init__(
self,
method: str,
path: str,
cache_tags: set[str] | None = None,
invalidate: set[str] | None = None,
) -> None:
def __init__(self, method: str, path: str) -> None:
self.method = method.upper()
self.path = path
self.cache_tags = cache_tags or set()
self.invalidate = invalidate or set()
class Registry:
@@ -77,16 +37,10 @@ class Registry:
def __init__(self) -> None:
self._routes: dict[tuple[str, str], Callable] = {}
def register(
self,
method: str,
path: str,
cache_tags: set[str] | None = None,
invalidate: set[str] | None = None,
):
def register(self, method: str, path: str):
def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn
fn._handler = Handler(method, path, cache_tags, invalidate) # type: ignore[attr-defined]
fn._handler = Handler(method, path) # type: ignore[attr-defined]
return fn
return decorator
@@ -98,6 +52,11 @@ class Registry:
registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
"""Refresh the pre-computed state for the given subsystems (or all)."""
state_store.populate(subsystems)
class NotFoundError(Exception):
"""Raised when a requested resource is not found."""
@@ -118,8 +77,6 @@ async def _handle_request(request: web.Request) -> web.Response:
if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404)
h = getattr(handler_fn, "_handler", None)
# Build body from JSON and merge query params. GET requests send params
# as URL query string, so they need to be treated as body for handlers.
body: dict[str, Any] | None = None
@@ -138,22 +95,6 @@ async def _handle_request(request: web.Request) -> web.Response:
else:
body = query_body
cache_key = json.dumps(
{
"method": request.method,
"path": request.path,
"query": query_dict,
"body": body,
},
sort_keys=True,
)
# Cache hit for read operations
if h and h.cache_tags:
cached = cache.get(cache_key)
if cached is not None:
return ok(cached)
try:
if body is not None:
result = handler_fn(request, body)
@@ -176,14 +117,6 @@ async def _handle_request(request: web.Request) -> web.Response:
)
return error(f"Internal error: {exc}", 500)
# Cache write for read operations
if h and h.cache_tags and isinstance(result, dict) and result.get("ok"):
cache.set(cache_key, result.get("data"), h.cache_tags)
# Invalidate on write operations
if h and h.invalidate:
cache.invalidate(*h.invalidate)
# Convert result to response if not already
if isinstance(result, web.Response):
return result
@@ -222,7 +155,6 @@ async def _handle_batch(request: web.Request) -> web.Response:
continue
op_body = op.get("body")
h = getattr(handler_fn, "_handler", None)
try:
result = handler_fn(None, op_body)
@@ -239,16 +171,14 @@ async def _handle_batch(request: web.Request) -> web.Response:
else:
results[op_id] = {"ok": True, "data": result}
# Invalidate on write
if h and h.invalidate:
cache.invalidate(*h.invalidate)
return ok(results)
def create_app() -> web.Application:
app = web.Application()
app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
app.router.add_route("POST", "/status/refresh", refresh_status)
app.router.add_route("POST", "/batch", _handle_batch)
app.router.add_route("*", "/{tail:.*}", _catch_all)
return app
@@ -258,6 +188,24 @@ async def _health(_request: web.Request) -> web.Response:
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call."""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system."""
try:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
body = None
subsystems = None
if body and "subsystems" in body:
subsystems = body["subsystems"]
state_store.populate(subsystems)
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def _catch_all(request: web.Request) -> web.Response:
"""Catch-all for registered routes."""
return await _handle_request(request)
@@ -306,6 +254,10 @@ def main() -> None:
loop.run_until_complete(site.start())
os.chmod(socket_path, 0o660)
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
logger.info("vacuum-walld listening on %s", socket_path)
try: