Files
vacuum-wall/webui/api/common.py
T
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

198 lines
7.7 KiB
Python

"""Shared API response helpers + daemon-proxy route factory.
Used by all API blueprints to produce consistent JSON responses per the
API response contract (``{"ok": true, "data": <value>}`` /
``{"ok": false, "error": "msg"}``) and to collapse the repetitive
``try: _ok(verb(EP, body)) except <typed> -> <code>`` boilerplate into a
single declarative ``daemon_route`` decorator.
The factory dispatches to the ``daemon.client`` verb imported into the
blueprint's own module namespace (resolved via ``sys.modules`` at request
time) so that tests can patch ``webui.api.<bp>.{get,post,patch,delete}``.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from flask import Blueprint, jsonify, request
from daemon.client import BadRequest, Conflict, NotFound
from daemon.iface import Endpoint
logger = logging.getLogger(__name__)
# Sentinel: send the verb with NO body argument (``verb(endpoint)``).
NO_BODY = object()
Verb = Callable[..., Any]
def _ok(data: Any = None):
"""Return a success JSON response."""
return jsonify({"ok": True, "data": data})
def _error(msg: str, code: int = 400):
"""Return an error JSON response with the given HTTP status code."""
return jsonify({"ok": False, "error": msg}), code
def _derive_rule(path: str) -> str:
"""Derive the Flask rule (relative to the blueprint url_prefix) from a
daemon endpoint path by dropping the leading subsystem segment.
``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` ->
``/issue/status``.
"""
parts = path.lstrip("/").split("/")
if len(parts) <= 1:
return "/"
return "/" + "/".join(parts[1:])
def _map_view_args(
view_args: dict[str, Any], params: dict[str, str] | None
) -> dict[str, Any]:
"""Map Flask view args onto daemon body keys.
``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg
not listed maps to itself (identity), so path params are always
forwarded and only renamed where the daemon expects a different key.
"""
result = dict(view_args)
for body_key, view_arg_name in (params or {}).items():
result.pop(view_arg_name, None)
result[body_key] = view_args[view_arg_name]
return result
def daemon_route(
endpoint: Endpoint,
bp: Blueprint,
rule: str | None = None,
methods: tuple[str, ...] | list[str] | None = None,
*,
params: dict[str, str] | None = None,
precheck: Callable[[Any, dict[str, Any]], None] | None = None,
body: Any | None = None,
transform: Callable[[Any, dict[str, Any], Any], Any] | None = None,
) -> Callable[..., Any]:
"""Decorator factory for thin daemon-proxy routes.
Args:
endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]``
is the daemon HTTP verb.
bp: The target blueprint.
rule: Flask rule relative to the blueprint ``url_prefix``. Defaults
to the endpoint path minus its leading subsystem segment.
methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``;
override where the UI verb differs from the daemon verb
(e.g. a UI ``PUT`` that maps to a daemon ``POST``).
params: ``{body_key: view_arg_name}`` renames for path params.
precheck: ``(json, view_args) -> None`` run before dispatch; raise
``ValueError``/``BadRequest`` for a 400 (preserves webui-side
validation the daemon does not perform). ``json`` is the raw
``request.get_json(silent=True)`` result.
body: How to build the daemon request body for non-GET verbs.
``None`` (default): ``{**json, **mapped_view_args}``;
``NO_BODY``: send no body argument;
a callable ``(request, view_args) -> dict``: custom body (raise
``ValueError`` for 400);
a ``dict``: fixed body (merged with mapped view args).
transform: ``(data, view_args, sent_body) -> data`` applied to the
daemon result before wrapping in ``_ok()``; may raise a typed
exception to emit an error (e.g. 400 when a result is invalid).
Returns:
A decorator that registers the route and returns a view function
whose ``__name__``/``__doc__`` are inherited from the decorated
function.
"""
if rule is None:
rule = _derive_rule(endpoint[1])
if methods is None:
methods = [endpoint[0]]
daemon_method = endpoint[0]
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
# The decorated view lives in the blueprint's module; its ``__globals__``
# is that module's namespace. Resolving the daemon verb here (instead of
# ``daemon.client`` directly) means tests patching
# ``webui.api.<bp>.{get,post,patch,delete}`` intercept the dispatch.
module_globals = fn.__globals__
def view(**view_args: Any) -> Any:
try:
json: Any = request.get_json(silent=True)
if precheck is not None:
precheck(json, view_args)
mapped = _map_view_args(view_args, params)
verb_fn: Verb = module_globals[daemon_method.lower()]
if daemon_method == "GET":
sent_body = mapped
data = (
verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint)
)
elif body is NO_BODY:
sent_body = None
data = verb_fn(endpoint)
elif callable(body):
built = body(request, view_args)
sent_body = built
data = (
verb_fn(endpoint, built)
if built is not None
else verb_fn(endpoint)
)
elif isinstance(body, dict):
sent_body = {**body, **mapped}
data = verb_fn(endpoint, sent_body)
else:
base = json if isinstance(json, dict) else {}
sent_body = {**base, **mapped}
data = verb_fn(endpoint, sent_body)
if transform is not None:
data = transform(data, view_args, sent_body)
return _ok(data)
except (BadRequest, ValueError) as exc:
return _error(str(exc), 400)
except NotFound as exc:
logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error(
"daemon error for %s %s: %s", daemon_method, endpoint[1], exc
)
return _error(str(exc), 500)
view.__name__ = fn.__name__
view.__doc__ = fn.__doc__
bp.add_url_rule(rule, view_func=view, methods=list(methods))
return view
return decorator
def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None:
"""Precheck: reject a non-dict JSON body (400).
A missing body (``None``) is tolerated and becomes ``{}`` downstream.
"""
if json is not None and not isinstance(json, dict):
raise ValueError("Request body must be a JSON object")
def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None:
"""Transform: discard the daemon result and return ``data: null``.
Matches routes that historically responded ``_ok(None)`` (the daemon
result was intentionally ignored by the caller).
"""
return None