status: cancel-all reverts pending changes to last applied config
- 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
This commit is contained in:
@@ -7,6 +7,9 @@ from lib.common import (
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
load_json,
|
||||
revert_to_applied,
|
||||
save_json,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
@@ -82,3 +85,66 @@ class TestDashboardFallback:
|
||||
status = {"pending_changes": True, "pending_diff": []}
|
||||
assert status["pending_changes"] is True
|
||||
assert status["pending_diff"] == []
|
||||
|
||||
|
||||
class TestRevertToApplied:
|
||||
def test_restores_snapshot_and_clears_pending(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"zones": {"lan": {"services": ["http"]}}}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
# Drift the file after apply (the "pending" state).
|
||||
dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}}
|
||||
dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied)
|
||||
dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
|
||||
save_json(path, dirty, indent=2)
|
||||
# Sanity: pending check would report drift.
|
||||
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty)
|
||||
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert ok and reason == ""
|
||||
|
||||
restored = load_json(path)
|
||||
assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash(
|
||||
restored
|
||||
)
|
||||
assert (
|
||||
_LAST_APPLIED_CONFIG_KEY in restored
|
||||
and restored[_LAST_APPLIED_CONFIG_KEY] == applied
|
||||
)
|
||||
assert (
|
||||
deep_diff(
|
||||
restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored)
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_no_baseline(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
save_json(path, {"zones": {}}, indent=2)
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert not ok
|
||||
assert reason
|
||||
# File untouched.
|
||||
assert _LAST_APPLIED_CONFIG_KEY not in load_json(path)
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
ok, reason = revert_to_applied(tmp_path / "nope.json")
|
||||
assert not ok
|
||||
assert reason
|
||||
|
||||
def test_stale_hash_but_snapshot_present(self, tmp_path):
|
||||
# Baseline recorded, hash stale (drifted) → still revertable.
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"a": 1}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
stamp = dict(stamped)
|
||||
stamp["a"] = 99 # edited without re-stamping
|
||||
save_json(path, stamp, indent=2)
|
||||
assert stamp[_APPLY_HASH_KEY] != config_hash(stamp)
|
||||
|
||||
ok, _ = revert_to_applied(path)
|
||||
assert ok
|
||||
restored = load_json(path)
|
||||
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
|
||||
|
||||
Reference in New Issue
Block a user