55309cfd86
- lib.common.revert_to_applied(): restore a config file from its
_last_applied_config snapshot (stamped hash); no baseline -> skip with
reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
subsystems; GET /firewall/config and the state collector strip the
internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
(CancelConfirm, reuses the pending-changes modal rows); the pending
changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
stripping, /api/status/cancel-all route, node tests for CancelConfirm;
firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
222 lines
7.9 KiB
Python
222 lines
7.9 KiB
Python
"""Tests for daemon/handlers/status.py — cancel-all (revert to applied)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
from unittest.mock import patch
|
|
|
|
from daemon.handlers import status
|
|
from lib.common import (
|
|
_APPLY_HASH_KEY,
|
|
_LAST_APPLIED_CONFIG_KEY,
|
|
config_hash,
|
|
stamp_applied,
|
|
)
|
|
|
|
|
|
def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]:
|
|
"""Build status_pending() output with the given subsystems pending.
|
|
|
|
Mirrors the real handler shape: every subsystem key is always present.
|
|
"""
|
|
data: dict[str, Any] = {
|
|
"firewall": {
|
|
"needs_apply": firewall,
|
|
"change_count": 1 if firewall else 0,
|
|
"changes": [],
|
|
},
|
|
}
|
|
total = 1 if firewall else 0
|
|
for name in ("dnsmasq", "nginx", "wireguard", "networkd"):
|
|
flagged = bool(flags.get(name, False))
|
|
data[name] = {
|
|
"pending_changes": flagged,
|
|
"summary": "x" if flagged else "Up to date",
|
|
"changes": [],
|
|
}
|
|
total += 1 if flagged else 0
|
|
data["total_changes"] = total
|
|
return data
|
|
|
|
|
|
class TestConfigPathResolution:
|
|
"""SYS_CONFIG_PATHS points at the constants the handlers actually use."""
|
|
|
|
def test_resolves_to_module_constants(self):
|
|
import daemon.handlers.dnsmasq as dm_h
|
|
import daemon.handlers.firewall as fw_h
|
|
import daemon.handlers.nginx as ngx_h
|
|
import lib.network as net_lib
|
|
import lib.wireguard as wg_lib
|
|
|
|
assert status._config_path("firewall") == fw_h.CONFIG_FILE
|
|
assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH
|
|
assert status._config_path("nginx") == ngx_h.CONFIG_FILE
|
|
assert status._config_path("wireguard") == wg_lib.CONFIG_PATH
|
|
assert status._config_path("networkd") == net_lib.CONFIG_FILE
|
|
|
|
def test_all_subsystems_mapped(self):
|
|
assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER)
|
|
|
|
|
|
class TestStatusCancelAll:
|
|
"""Test the cancel-all endpoint.
|
|
|
|
Patches the ``_config_path`` seam directly since it resolves the real
|
|
module constants via getattr at call time.
|
|
"""
|
|
|
|
_fake_pending_all: ClassVar[dict[str, Any]] = _pending()
|
|
|
|
def _write_config(
|
|
self, path: Path, cfg: dict[str, Any], baseline: dict | None
|
|
) -> None:
|
|
"""Write *cfg* (with an attached applied baseline when *baseline* given)."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
stamped = dict(cfg)
|
|
if baseline is not None:
|
|
b = dict(baseline)
|
|
stamp_applied(b)
|
|
stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline)
|
|
stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY]
|
|
path.write_text(json.dumps(stamped, indent=2) + "\n")
|
|
|
|
def _read_config(self, path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text())
|
|
|
|
@patch("daemon.handlers.status.status_pending")
|
|
@patch("daemon.handlers.status.refresh_state")
|
|
def test_nothing_pending(self, mock_refresh, mock_pending):
|
|
mock_pending.return_value = self._fake_pending_all
|
|
with patch("daemon.handlers.status._config_path", side_effect=AssertionError):
|
|
result = status.status_cancel_all(None, None)
|
|
assert result == {"cancelled": [], "skipped": {}, "errors": {}}
|
|
mock_refresh.assert_not_called()
|
|
|
|
def test_reverts_pending_subsystem(self, tmp_path):
|
|
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
|
applied = {"dns": {"upstreams": ["8.8.8.8"]}}
|
|
# Baseline applied, then the user edited the config (drift).
|
|
self._write_config(
|
|
paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied
|
|
)
|
|
|
|
dirty = self._read_config(paths["dnsmasq"])
|
|
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending
|
|
|
|
with (
|
|
patch(
|
|
"daemon.handlers.status.status_pending",
|
|
return_value=_pending(dnsmasq=True),
|
|
),
|
|
patch("daemon.handlers.status.refresh_state") as mock_refresh,
|
|
patch(
|
|
"daemon.handlers.status._config_path",
|
|
side_effect=lambda name: paths[name],
|
|
),
|
|
):
|
|
result = status.status_cancel_all(None, None)
|
|
|
|
assert result == {
|
|
"cancelled": ["dnsmasq"],
|
|
"skipped": {},
|
|
"errors": {},
|
|
}
|
|
mock_refresh.assert_called_once_with(status.SYS_ORDER)
|
|
|
|
restored = self._read_config(paths["dnsmasq"])
|
|
assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied
|
|
# Cancel restored the baseline: the pending check now passes.
|
|
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
|
|
|
|
def test_skipped_when_no_baseline(self, tmp_path):
|
|
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
|
cfg_no_baseline = {"peers": {"a": {}}}
|
|
self._write_config(paths["wireguard"], cfg_no_baseline, None)
|
|
|
|
with (
|
|
patch(
|
|
"daemon.handlers.status.status_pending",
|
|
return_value=_pending(wireguard=True),
|
|
),
|
|
patch("daemon.handlers.status.refresh_state") as mock_refresh,
|
|
patch(
|
|
"daemon.handlers.status._config_path",
|
|
side_effect=lambda name: paths[name],
|
|
),
|
|
):
|
|
result = status.status_cancel_all(None, None)
|
|
|
|
assert result["cancelled"] == []
|
|
assert "WireGuard" in result["skipped"]
|
|
assert result["errors"] == {}
|
|
mock_refresh.assert_not_called()
|
|
# File left untouched.
|
|
assert self._read_config(paths["wireguard"]) == cfg_no_baseline
|
|
|
|
def test_error_in_one_subsystem_others_still_cancel(self, tmp_path):
|
|
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
|
for name in ("firewall", "nginx"):
|
|
self._write_config(
|
|
paths[name],
|
|
{"zones": {}} if name == "firewall" else {"domains": {}},
|
|
{"x": 1},
|
|
)
|
|
|
|
def fake_revert(path):
|
|
if "nginx" in str(path):
|
|
raise RuntimeError("disk full")
|
|
return True, ""
|
|
|
|
with (
|
|
patch(
|
|
"daemon.handlers.status.status_pending",
|
|
return_value=_pending(firewall=True, nginx=True),
|
|
),
|
|
patch("daemon.handlers.status.refresh_state"),
|
|
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
|
|
patch(
|
|
"daemon.handlers.status._config_path",
|
|
side_effect=lambda name: paths[name],
|
|
),
|
|
):
|
|
result = status.status_cancel_all(None, None)
|
|
|
|
# SYS_ORDER runs firewall before nginx.
|
|
assert result["cancelled"] == ["firewall"]
|
|
assert result["errors"] == {"Nginx": "disk full"}
|
|
assert result["skipped"] == {}
|
|
|
|
def test_order_matches_sys_order(self, tmp_path):
|
|
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
|
|
call_order = []
|
|
|
|
def fake_revert(path):
|
|
call_order.append(next(n for n, p in paths.items() if p == path))
|
|
return True, ""
|
|
|
|
with (
|
|
patch(
|
|
"daemon.handlers.status.status_pending",
|
|
return_value=_pending(
|
|
firewall=True,
|
|
dnsmasq=True,
|
|
nginx=True,
|
|
wireguard=True,
|
|
networkd=True,
|
|
),
|
|
),
|
|
patch("daemon.handlers.status.refresh_state"),
|
|
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
|
|
patch(
|
|
"daemon.handlers.status._config_path",
|
|
side_effect=lambda name: paths[name],
|
|
),
|
|
):
|
|
result = status.status_cancel_all(None, None)
|
|
|
|
assert result["cancelled"] == status.SYS_ORDER
|
|
assert call_order == status.SYS_ORDER
|