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:
+73
-3
@@ -16,21 +16,87 @@ from typing import Any
|
||||
from passlib.hash import sha256_crypt
|
||||
|
||||
_APPLY_HASH_KEY = "_last_applied_hash"
|
||||
_LAST_APPLIED_CONFIG_KEY = "_last_applied_config"
|
||||
_APPLY_META_KEYS = (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY)
|
||||
|
||||
|
||||
def strip_apply_meta(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return *cfg* without any apply bookkeeping keys.
|
||||
|
||||
Strips both the last-applied hash and the last-applied config snapshot
|
||||
so the returned dict reflects only real configuration.
|
||||
"""
|
||||
return {k: v for k, v in cfg.items() if k not in _APPLY_META_KEYS}
|
||||
|
||||
|
||||
def config_hash(cfg: dict[str, Any]) -> str:
|
||||
"""Compute a SHA-256 hash of *cfg* excluding the ``_last_applied_hash`` key.
|
||||
"""Compute a SHA-256 hash of *cfg*, ignoring apply bookkeeping keys.
|
||||
|
||||
Args:
|
||||
cfg: Config dict, possibly containing ``_last_applied_hash``.
|
||||
cfg: Config dict, possibly containing ``_last_applied_hash`` and
|
||||
``_last_applied_config``.
|
||||
|
||||
Returns:
|
||||
Hex digest of the stripped config JSON.
|
||||
"""
|
||||
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
||||
clean = strip_apply_meta(cfg)
|
||||
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record that *cfg* is the last applied configuration.
|
||||
|
||||
Writes both the content snapshot (``_last_applied_config``) and its hash
|
||||
(``_last_applied_hash``) so a later pending check can detect drift and a
|
||||
diff can report exactly which fields changed.
|
||||
"""
|
||||
cfg[_APPLY_HASH_KEY] = config_hash(cfg)
|
||||
cfg[_LAST_APPLIED_CONFIG_KEY] = strip_apply_meta(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
|
||||
"""Return a list of field-level changes between two configurations.
|
||||
|
||||
Each entry is ``{"path", "action", "old", "new"}`` where *action* is one
|
||||
of ``"added"``, ``"removed"`` or ``"changed"``. Dicts are recursed with
|
||||
dotted paths; lists of equal length are compared element-by-element,
|
||||
while any other value that differs is reported as a single change.
|
||||
Apply bookkeeping keys are ignored.
|
||||
"""
|
||||
if isinstance(old, dict):
|
||||
old = strip_apply_meta(old)
|
||||
if isinstance(new, dict):
|
||||
new = strip_apply_meta(new)
|
||||
out: list[dict[str, Any]] = []
|
||||
_diff_nodes(old, new, prefix, out)
|
||||
return out
|
||||
|
||||
|
||||
def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> None:
|
||||
"""Recursively collect field-level changes from *old* into *new*."""
|
||||
if isinstance(old, dict) and isinstance(new, dict):
|
||||
for key in sorted(set(old) | set(new)):
|
||||
child = f"{path}.{key}" if path else str(key)
|
||||
if key in old and key in new:
|
||||
_diff_nodes(old[key], new[key], child, out)
|
||||
elif key in old:
|
||||
out.append(
|
||||
{"path": child, "action": "removed", "old": old[key], "new": None}
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
{"path": child, "action": "added", "old": None, "new": new[key]}
|
||||
)
|
||||
return
|
||||
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
|
||||
for i, (o, n) in enumerate(zip(old, new, strict=True)):
|
||||
_diff_nodes(o, n, f"{path}[{i}]", out)
|
||||
return
|
||||
if old != new:
|
||||
out.append({"path": path, "action": "changed", "old": old, "new": new})
|
||||
|
||||
|
||||
def validate_interface_name(name: str) -> str:
|
||||
"""Validate a Linux network interface name.
|
||||
|
||||
@@ -210,8 +276,10 @@ def get_interface_ip(iface: str) -> str | None:
|
||||
|
||||
__all__ = [
|
||||
"_APPLY_HASH_KEY",
|
||||
"_LAST_APPLIED_CONFIG_KEY",
|
||||
"_hash_password",
|
||||
"config_hash",
|
||||
"deep_diff",
|
||||
"deep_merge",
|
||||
"ensure_dirs",
|
||||
"get_interface_ip",
|
||||
@@ -219,5 +287,7 @@ __all__ = [
|
||||
"run",
|
||||
"run_proc",
|
||||
"save_json",
|
||||
"stamp_applied",
|
||||
"strip_apply_meta",
|
||||
"validate_interface_name",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user