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:
+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"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user