- lib.common.revert_to_applied(): restore a config file from its
_last_applied_config snapshot (stamped hash); no baseline -> skip with
reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
subsystems; GET /firewall/config and the state collector strip the
internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
(CancelConfirm, reuses the pending-changes modal rows); the pending
changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
stripping, /api/status/cancel-all route, node tests for CancelConfirm;
firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
6.4 KiB
State Model Reference
Authoritative reference for the shapes returned by the daemon's
pre-computed state store (lib/state.py), collected per subsystem and
pushed over the WebSocket (snapshot on connect, per-subsystem deltas
after every change).
Python schemas live in lib/schema.py (TypedDicts); each collector's
return annotation references them.
Shared notes
- Every collector return carries a top-level
timestamp(ISO-8601). - Subsystems with a declarative config expose pending state as a status
dict:
status: {"pending_changes": bool}, except firewall, which usespending: {config_pending() result}. - A subsystem whose collection failed holds
null/Nonein the state store — WS snapshots and deltas skipnullpayloads so a failed collector never overwrites good client data. - Config-backed subsystems record their applied baseline inside the config
file itself:
_last_applied_config(the full merged config at last apply) and_last_applied_hash(its SHA-256). A hash subsystem'sstatus.pending_changesis true when the current (merged) config hash differs from the recorded hash;status.pending_difflists the field changes since that snapshot. All apply operations (including firewallconfig_apply) re-stamp the baseline. These bookkeeping keys are internal and stripped from every state/API config payload. Canceling pending changes (POST /api/status/cancel-all) restores a pending config file from its snapshot; a subsystem with no recorded baseline (never applied) is reported as skipped, not reset.
State shape summary
state_store.get(<subsystem>) returns:
| Subsystem | Poll | Volatile fields | Top-level keys |
|---|---|---|---|
firewall |
30s | interfaces[].ips, interfaces[].ipv6 |
config, active_zones, interfaces, available_services, zones, rich_rules, pending, timestamp |
dnsmasq |
10s | (none) | config, status, leases, timestamp |
nginx |
60s | (none) | config, domains, status, timestamp |
acme |
300s | (none) | certs, email, account, timestamp |
wireguard |
10s | status.peers[].transfer_received/.transfer_sent/.latest_handshake and the same three under status.classes[].peers[] |
config, status, peers, timestamp |
networkd |
10s | interfaces[].addresses |
config, interfaces, status, timestamp |
system |
1s | load, memory, swap, traffic |
load, memory, swap, traffic, timestamp |
Poll intervals are overridable via VACUUM_WALL_POLL_INTERVALS
(subsystem:seconds,subsystem:seconds).
Firewall
Top-level FirewallState:
{
config: {}, // config/firewall/config.json
active_zones: {zone: [iface]}, // zones with assigned interfaces
interfaces: [ // ip link/addr parsing
{name, mac, state, mtu, ips, ipv6, zone}
],
available_services: [str], // firewall-cmd --get-services
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",
// "icmp-blocks", "module", "rich-rules"
rich_rules: {zone: [str]}, // raw firewalld rich-rule strings,
// re-derived from zones (NO ids —
// deletion-by-id uses config.zones[].rich_rules)
pending: { // config_pending() (lib/firewall.py)
pending: [...], needs_apply: bool,
unmanaged_zones: {zone: {interfaces: [...]}}
},
timestamp: str,
}
Notes:
interfaces[].ips/interfaces[].ipv6hold"ip/prefix"strings (IPv6 list is separate).- The zone dict's rich-rules key is HYPHENATED (
"rich-rules");state.rich_rulesis the snake_case top-level re-derivation.
Dnsmasq
{
config: {}, // config/dnsmasq/config.json, deep-merged
status: {
service_active: bool, config_file_exists: bool,
active_leases: int, pending_changes: bool
},
leases: [
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
],
timestamp: str,
}
Nginx
{
config: {}, // config/nginx/config.json
domains: [ // flattened: one entry per domain+path
{domain, path, backend, online, force_ssl, backend_name, cert,
[is_management], [is_websocket]}
],
status: {pending_changes: bool},
timestamp: str,
}
ACME
{
certs: [ // list_certs(); extra keys possible
{domain, expiry, renewed, status, days_remaining, ...}
],
email: str,
account: {registered, email, ca, key_length},
timestamp: str,
}
WireGuard
{
config: {}, // private_key stripped from interface
// AND every access class
status: {
up: bool, // true when ANY managed iface is up
interface: {}, peers: [], // legacy single interface (wg0)
classes: {class: {up, interface, peers}}, // per wg-<class>
pending_changes: bool
},
peers: [ // config peers, private keys stripped
{name, public_key, endpoint, allowed_ips,
persistent_keepalive, preshared_key, ...}
],
timestamp: str,
}
Runtime peers (status.peers[], status.classes[].peers[]) carry:
public_key, endpoint, allowed_ips, latest_handshake,
transfer_received, transfer_sent, persistent_keepalive.
Networkd
Matches parse_networkctl_status() output (lib/network.py) exactly:
{
config: {}, // config/network/config.json
interfaces: {iface: { // flat runtime entry per interface;
addresses: ["ip/prefix"], // a single combined addresses list
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
state, link}
},
status: {pending_changes: bool},
timestamp: str,
}
The parser does not filter lo; clients that don't want it filter
client-side.
System
Metrics only — no config, no pending state.
{
load: {load1, load5, load15},
memory: {total, available, used, used_pct}, // bytes; 0-100
swap: {total, used, used_pct}, // bytes; 0-100
traffic: {iface: {rx_bytes, tx_bytes,
rx_packets, tx_packets}},
timestamp: str,
}
All four metric fields are volatile (1s tick cadence); structural diffs only fire on interface-set changes.