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:
@@ -0,0 +1,84 @@
|
||||
"""Tests for lib.common apply-metadata and diff helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
|
||||
|
||||
class TestStripApplyMeta:
|
||||
def test_strips_both_keys(self):
|
||||
cfg = {"a": 1, _APPLY_HASH_KEY: "h", _LAST_APPLIED_CONFIG_KEY: {}}
|
||||
assert strip_apply_meta(cfg) == {"a": 1}
|
||||
|
||||
def test_missing_keys(self):
|
||||
assert strip_apply_meta({"a": 1}) == {"a": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, _APPLY_HASH_KEY: "h"}
|
||||
strip_apply_meta(cfg)
|
||||
assert _APPLY_HASH_KEY in cfg
|
||||
|
||||
|
||||
class TestConfigHashIgnoresMeta:
|
||||
def test_hash_unaffected_by_metadata(self):
|
||||
cfg = {"a": 1}
|
||||
stamped = {"a": 1, _APPLY_HASH_KEY: "x", _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
|
||||
assert config_hash(cfg) == config_hash(stamped)
|
||||
|
||||
|
||||
class TestStampApplied:
|
||||
def test_records_snapshot_and_hash(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == {"a": 1}
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
|
||||
def test_stable(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
# A pending-style check: hash matches the current (stripped) config.
|
||||
assert _APPLY_HASH_KEY in cfg and cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
# No drift → no diff.
|
||||
assert (
|
||||
deep_diff(cfg.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(cfg))
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
class TestDeepDiff:
|
||||
def test_identical_empty(self):
|
||||
assert deep_diff({"a": 1, _APPLY_HASH_KEY: "h"}, {"a": 1}) == []
|
||||
|
||||
def test_changed_scalar(self):
|
||||
diff = deep_diff({"a": 1}, {"a": 2})
|
||||
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
|
||||
|
||||
def test_added_removed(self):
|
||||
added = deep_diff({}, {"a": 1})
|
||||
assert added[0]["action"] == "added" and added[0]["new"] == 1
|
||||
removed = deep_diff({"a": 1}, {})
|
||||
assert removed[0]["action"] == "removed" and removed[0]["old"] == 1
|
||||
|
||||
def test_nested_and_list_index(self):
|
||||
old = {"z": {"svc": ["http"], "ranges": [{"ip": "10.0.0.1", "n": 1}]}}
|
||||
new = {"z": {"svc": ["http", "ssh"], "ranges": [{"ip": "10.0.0.2", "n": 1}]}}
|
||||
paths = {d["path"] for d in deep_diff(old, new)}
|
||||
assert "z.svc" in paths
|
||||
assert "z.ranges[0].ip" in paths
|
||||
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
||||
|
||||
|
||||
class TestDashboardFallback:
|
||||
def test_hash_subsystem_unchanged_generic(self):
|
||||
# Guards that a pending status without a snapshot still yields a
|
||||
# renderable pending flag (frontend falls back to a generic line).
|
||||
status = {"pending_changes": True, "pending_diff": []}
|
||||
assert status["pending_changes"] is True
|
||||
assert status["pending_diff"] == []
|
||||
+72
-3
@@ -244,18 +244,87 @@ class TestConfigPending:
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_detects_unmanaged_zones(self, mock_cfg):
|
||||
# A custom live zone not in config is flagged as unmanaged.
|
||||
mock_cfg.return_value = {"zones": {}}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"guest": {
|
||||
"interfaces": ["eth5"],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert "public" in result["unmanaged_zones"]
|
||||
assert "guest" in result["unmanaged_zones"]
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_built_in_zones_not_unmanaged(self, mock_cfg):
|
||||
# firewalld built-in zones are always present and must not be
|
||||
# reported as unmanaged, so they never surface as noise.
|
||||
mock_cfg.return_value = {"zones": {}}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {"interfaces": ["eth0"], "services": [], "masquerade": True},
|
||||
"trusted": {"interfaces": ["lo"], "services": [], "masquerade": False},
|
||||
"dmz": {"interfaces": ["eth7"], "services": [], "masquerade": False},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert result["unmanaged_zones"] == {}
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_public_masquerade_not_pending(self, mock_cfg):
|
||||
# public zone masquerade is driven by apply's propagation step, so a
|
||||
# config-vs-live masquerade mismatch on public is not a pending change.
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
state = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert not any(c["type"] == "masquerade" for c in result["pending"])
|
||||
assert result["needs_apply"] is False
|
||||
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_non_public_masquerade_is_pending(self, mock_cfg):
|
||||
# A non-public zone with a masquerade mismatch IS a pending change.
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"internal": {
|
||||
"interfaces": ["eth1"],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
state = {
|
||||
"zones": {
|
||||
"internal": {
|
||||
"interfaces": ["eth1"],
|
||||
"services": [],
|
||||
"masquerade": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = firewall.config_pending(state)
|
||||
assert any(
|
||||
c["type"] == "masquerade" and c["zone"] == "internal"
|
||||
for c in result["pending"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user