state: applied-config snapshots + per-field pending diffs

- lib/common: stamp_applied() now records a _last_applied_config
  snapshot alongside the hash; strip_apply_meta() centralizes
  bookkeeping-key stripping; deep_diff() reports field-level changes
- state collectors (dnsmasq/nginx/wireguard/networkd) expose
  pending_diff so the dashboard can show exactly which fields
  changed since the last apply (wireguard diff excludes
  private_key paths)
- dashboard pending-changes card renders per-change lines with a
  generic fallback when no snapshot is recorded
- firewall: firewalld built-in zones no longer flagged as
  unmanaged; public-zone masquerade skipped in pending changes
  since apply drives it via nftables propagation
- schema: PendingChange TypedDict; pending_diff on DnsmasqStatus /
  WgStatus; tests in test_common.py, test_firewall.py, test_state.py
This commit is contained in:
2026-08-21 00:59:19 +00:00
parent a77cee821b
commit 30b51ad7d3
14 changed files with 525 additions and 62 deletions
+59
View File
@@ -1,5 +1,6 @@
"""Tests for lib/state.py — state store and collect functions."""
import json
from unittest.mock import patch
from lib.state import State, state
@@ -155,6 +156,64 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
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 = {
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "10.4.20.101",
"end": "10.4.20.200",
"lease_time": "1h",
"gateway": "10.4.20.1",
}
],
"static_leases": [],
},
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
}
cfg = {
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "10.4.20.100",
"end": "10.4.20.200",
"lease_time": "12h",
"gateway": "10.4.20.1",
}
],
"static_leases": [],
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
_LAST_APPLIED_CONFIG_KEY: applied,
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
assert result["status"]["pending_changes"] is True
paths = {d["path"] for d in result["status"]["pending_diff"]}
assert "dhcp.ranges[0].start" in paths
assert "dhcp.ranges[0].lease_time" in paths
# Apply metadata must not leak into the returned config.
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
assert _APPLY_HASH_KEY not in result["config"]
class TestCollectFailure:
def test_state_clears_on_failure(self):