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.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+31 -19
View File
@@ -3,7 +3,9 @@
import json
from unittest.mock import patch
import lib
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
from lib.state import State, state
@@ -51,9 +53,9 @@ class TestState:
class TestCollectAll:
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_returns_dict(self, mock_run):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -88,10 +90,10 @@ class TestCollectAll:
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -144,10 +146,10 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions")
@patch("lib.state.run")
@patch("daemon.collectors.firewall.get_service_descriptions")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -168,11 +170,11 @@ class TestCollectAll:
mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
from lib.state import _collect_dnsmasq
from daemon.collectors.dnsmasq import _collect_dnsmasq
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
@@ -181,12 +183,12 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
from daemon.collectors.dnsmasq import _collect_dnsmasq
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = {
@@ -226,7 +228,9 @@ class TestCollectAll:
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
monkeypatch.setattr(
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
@@ -257,15 +261,19 @@ class TestAcmeCollectNonFatal:
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
def test_list_failure_yields_empty_certs_and_error(self):
from lib.state import _collect_acme
from daemon.collectors.acme import _collect_acme
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"),
),
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
@@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal:
assert "exit code 2" in result["status"]["error"]
def test_success_reports_no_error(self):
from lib.state import _collect_acme
from daemon.collectors.acme import _collect_acme
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()