"""Aggregate status handler. Exposes pending changes across all subsystems, a single apply-all endpoint that invokes each subsystem's apply in the correct order, and a cancel-all endpoint that reverts pending edits to the last applied config. """ from __future__ import annotations import logging from pathlib import Path from typing import Any from daemon.handlers import dnsmasq as _dnsmasq_h from daemon.handlers import firewall as _firewall_h from daemon.handlers import nginx as _nginx_h from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config from daemon.handlers.firewall import config_apply as firewall_config_apply from daemon.handlers.network import apply_all as network_apply_all from daemon.handlers.nginx import apply as nginx_apply from daemon.handlers.wireguard import apply as wireguard_apply from daemon.iface import ( GET_STATUS_PENDING, POST_STATUS_APPLY_ALL, POST_STATUS_CANCEL_ALL, ) from daemon.server import refresh_state, registry from lib import network as _net from lib import wireguard as _wg from lib.common import revert_to_applied from lib.firewall import fw_change_summary from lib.state import state as state_store logger = logging.getLogger(__name__) SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"] SYS_LABELS = { "networkd": "Network", "firewall": "Firewall", "wireguard": "WireGuard", "dnsmasq": "DHCP/DNS", "nginx": "Nginx", } SYS_APPLY = { "networkd": network_apply_all, "firewall": firewall_config_apply, "wireguard": wireguard_apply, "dnsmasq": dnsmasq_apply_config, "nginx": nginx_apply, } # (module, attribute) pairs for each subsystem's on-disk config path. # Resolved at call time so tests can monkeypatch the module constants. SYS_CONFIG_PATHS: dict[str, tuple[Any, str]] = { "firewall": (_firewall_h, "CONFIG_FILE"), "dnsmasq": (_dnsmasq_h, "CONFIG_PATH"), "nginx": (_nginx_h, "CONFIG_FILE"), "wireguard": (_wg, "CONFIG_PATH"), "networkd": (_net, "CONFIG_FILE"), } @registry.register(GET_STATUS_PENDING) def status_pending(_request: Any, _body: Any) -> dict[str, Any]: """Aggregate pending changes across all subsystems. Returns: Dict with per-subsystem pending status and total change count. """ fw = state_store.get("firewall") or {} pending_fw = fw.get("pending", {}) fw_needs_apply = pending_fw.get("needs_apply", False) fw_pending_list = pending_fw.get("pending", []) fw_changes = [] for c in fw_pending_list: zone = c.get("zone", "unknown") ctype = c.get("type", "unknown") summary = fw_change_summary(zone, ctype, c) fw_changes.append({"summary": summary, "detail": ""}) fw_result = { "needs_apply": fw_needs_apply, "change_count": len(fw_changes), "changes": fw_changes, } hash_subsystems = { "dnsmasq": _hash_subsystem("dnsmasq", state_store.get("dnsmasq")), "nginx": _hash_subsystem("nginx", state_store.get("nginx")), "wireguard": _hash_subsystem("wireguard", state_store.get("wireguard")), "networkd": _hash_subsystem("networkd", state_store.get("networkd")), } total = len(fw_changes) for _name, result in hash_subsystems.items(): total += len(result["changes"]) return { "firewall": fw_result, "dnsmasq": hash_subsystems["dnsmasq"], "nginx": hash_subsystems["nginx"], "wireguard": hash_subsystems["wireguard"], "networkd": hash_subsystems["networkd"], "total_changes": total, } @registry.register(POST_STATUS_APPLY_ALL) def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]: """Apply pending changes for all subsystems in dependency order. Order: network -> firewall -> wireguard -> dnsmasq -> nginx. Returns: Dict with applied subsystems and any errors encountered. """ applied = [] errors = {} pending_data = status_pending(None, None) fw_pending = pending_data["firewall"]["needs_apply"] hash_pending = { "dnsmasq": pending_data["dnsmasq"]["pending_changes"], "nginx": pending_data["nginx"]["pending_changes"], "wireguard": pending_data["wireguard"]["pending_changes"], "networkd": pending_data["networkd"]["pending_changes"], } for name in SYS_ORDER: if name == "firewall": if not fw_pending: continue else: if not hash_pending.get(name, False): continue handler = SYS_APPLY[name] try: handler(None, None) applied.append(name) except Exception as exc: label = SYS_LABELS.get(name, name) errors[label] = str(exc) logger.error("Apply-all failed for %s: %s", name, exc) refresh_state(SYS_ORDER) return {"applied": applied, "errors": errors} def _config_path(name: str) -> Path: """Return the on-disk config path for subsystem *name*. Resolved via the owning module at call time so tests can monkeypatch the module constants (e.g. ``lib.wireguard.CONFIG_PATH``). """ module, attr = SYS_CONFIG_PATHS[name] return getattr(module, attr) @registry.register(POST_STATUS_CANCEL_ALL) def status_cancel_all(_request: Any, _body: Any) -> dict[str, Any]: """Revert pending changes for all subsystems to the last applied config. Restores each pending subsystem's config file from its recorded ``_last_applied_config`` snapshot, discarding unapplied edits. Subsystems without a recorded baseline (never applied) are skipped with a reason instead of being reset. No live-system commands run — cancel only touches the declarative config files. Returns: Dict with ``cancelled`` (list of reverted subsystems), ``skipped`` (label -> reason), and ``errors`` (label -> message). """ pending_data = status_pending(None, None) fw_pending = pending_data["firewall"]["needs_apply"] hash_pending = { "dnsmasq": pending_data["dnsmasq"]["pending_changes"], "nginx": pending_data["nginx"]["pending_changes"], "wireguard": pending_data["wireguard"]["pending_changes"], "networkd": pending_data["networkd"]["pending_changes"], } cancelled: list[str] = [] skipped: dict[str, str] = {} errors: dict[str, str] = {} for name in SYS_ORDER: pending = fw_pending if name == "firewall" else hash_pending.get(name, False) if not pending: continue label = SYS_LABELS.get(name, name) try: ok, reason = revert_to_applied(_config_path(name)) if ok: cancelled.append(name) logger.info("Cancelled pending changes for %s", name) else: skipped[label] = reason logger.warning("Cancel-all skipped %s: %s", label, reason) except Exception as exc: errors[label] = str(exc) logger.error("Cancel-all failed for %s: %s", name, exc) if cancelled: refresh_state(SYS_ORDER) return {"cancelled": cancelled, "skipped": skipped, "errors": errors} def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]: """Build pending result for a hash-based subsystem.""" if state is None: return {"pending_changes": False, "summary": "Up to date", "changes": []} status = state.get("status", {}) pending = status.get("pending_changes", False) label = SYS_LABELS.get(name, name) if pending: summary = f"{label} configuration has unapplied changes" return { "pending_changes": True, "summary": summary, "changes": [{"summary": summary, "detail": ""}], } return {"pending_changes": False, "summary": "Up to date", "changes": []}