faa076370d
- 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.
198 lines
7.7 KiB
Python
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
|