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
+51 -9
View File
@@ -13,7 +13,16 @@ from pathlib import Path
from typing import Any, ClassVar
from lib import schema
from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
deep_diff,
load_json,
run,
run_proc,
strip_apply_meta,
)
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
@@ -637,7 +646,13 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
cfg
)
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe_cfg = strip_apply_meta(cfg)
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
pending_diff = deep_diff(snap, safe_cfg)
return {
"config": safe_cfg,
"status": {
@@ -645,6 +660,7 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
@@ -732,11 +748,20 @@ def _collect_nginx() -> schema.NginxState:
cfg
)
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe_cfg = strip_apply_meta(cfg)
nginx_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
nginx_pending_diff = deep_diff(snap, safe_cfg)
return {
"config": safe_cfg,
"domains": domains,
"status": {"pending_changes": pending_changes},
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
@@ -939,7 +964,7 @@ def _collect_wireguard() -> schema.WgState:
)
# Safe config (strip private keys from interface and access classes)
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
@@ -1116,6 +1141,16 @@ def _collect_wireguard() -> schema.WgState:
status["up"] = True
status["pending_changes"] = pending_changes
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
# `safe` has private keys stripped; drop any private-key paths so
# the pending summary never exposes key material.
pending_diff = [
d for d in deep_diff(snap, safe) if "private_key" not in d["path"]
]
status["pending_diff"] = pending_diff
return {
"config": safe,
"status": status,
@@ -1164,7 +1199,14 @@ def _collect_networkd() -> schema.NetworkdState:
] != config_hash(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {"pending_changes": pending_changes}
net_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
net_pending_diff = deep_diff(snap, safe_net_cfg)
net_status["pending_diff"] = net_pending_diff
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
@@ -1173,21 +1215,21 @@ def _collect_networkd() -> schema.NetworkdState:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}