Surface reconciliation in the dashboard and keep the view read-only
Two gaps in the baseline-reconciliation feature: - The dashboard claimed to summarise acknowledged drift, but renderIntegrity() never rendered it. Add a `recon` chip to the integrity summary and a Reconciliation state-card (accepted/ok/stale counts plus stale items). - A read-only GET /api/integrity could write the store: list() flushed a TTL-lapsed ok->stale transition to disk, violating the read-only dashboard invariant. Add list(persist=False) and use it from the web summary path so staleness is evaluated in memory without touching the file. Covered by a new test asserting integrity_report() never rewrites the store. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dbbe35b3fc
commit
9cbdb989ac
4 changed files with 49 additions and 8 deletions
|
|
@ -244,9 +244,11 @@ class ReconcileStore:
|
||||||
|
|
||||||
# -- queries -----------------------------------------------------------
|
# -- queries -----------------------------------------------------------
|
||||||
def list(self, stale_only: bool = False, live_fps: dict | None = None,
|
def list(self, stale_only: bool = False, live_fps: dict | None = None,
|
||||||
now: datetime | None = None) -> list[Record]:
|
now: datetime | None = None, persist: bool = True) -> list[Record]:
|
||||||
"""All records, re-evaluating staleness against TTL and any supplied
|
"""All records, re-evaluating staleness against TTL and any supplied
|
||||||
live state. Persists newly detected transitions (the lazy write-back).
|
live state. Persists newly detected transitions (the lazy write-back)
|
||||||
|
unless ``persist=False`` — the read-only dashboard path needs the
|
||||||
|
recomputed status without touching disk.
|
||||||
|
|
||||||
TTL expiry is always authoritative. A content-bound (fim/pkgfile) ack is
|
TTL expiry is always authoritative. A content-bound (fim/pkgfile) ack is
|
||||||
only re-checked when ``live_fps`` actually carries that kind's data; with
|
only re-checked when ``live_fps`` actually carries that kind's data; with
|
||||||
|
|
@ -257,7 +259,8 @@ class ReconcileStore:
|
||||||
live_fps = live_fps or {}
|
live_fps = live_fps or {}
|
||||||
for rec in self._records.values():
|
for rec in self._records.values():
|
||||||
self._refresh_listed(rec, live_fps, now)
|
self._refresh_listed(rec, live_fps, now)
|
||||||
self.flush_stale()
|
if persist:
|
||||||
|
self.flush_stale()
|
||||||
out = [r for r in self._records.values()
|
out = [r for r in self._records.values()
|
||||||
if not stale_only or r.status == "stale"]
|
if not stale_only or r.status == "stale"]
|
||||||
return sorted(out, key=lambda r: (r.kind, r.target))
|
return sorted(out, key=lambda r: (r.kind, r.target))
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,8 @@ function renderIntegrity(){
|
||||||
"watchdog "+(checks.watchdog||"unknown"),
|
"watchdog "+(checks.watchdog||"unknown"),
|
||||||
"fim "+(checks.fim_baseline||"unknown"),
|
"fim "+(checks.fim_baseline||"unknown"),
|
||||||
"pkgdb "+(checks.pkgdb_anchor||"unknown"),
|
"pkgdb "+(checks.pkgdb_anchor||"unknown"),
|
||||||
"siglevel "+(checks.pacman_siglevel||"unknown")
|
"siglevel "+(checks.pacman_siglevel||"unknown"),
|
||||||
|
"recon "+(checks.reconciliation||"unknown")
|
||||||
]));
|
]));
|
||||||
summary.append(el("div","fine","Generated "+new Date((report.generated_at||0)*1000).toLocaleString()));
|
summary.append(el("div","fine","Generated "+new Date((report.generated_at||0)*1000).toLocaleString()));
|
||||||
box.append(summary);
|
box.append(summary);
|
||||||
|
|
@ -653,6 +654,28 @@ function renderIntegrity(){
|
||||||
footCard.append(paths);
|
footCard.append(paths);
|
||||||
grid.append(footCard);
|
grid.append(footCard);
|
||||||
|
|
||||||
|
const recon = report.reconciliation || {total:0, ok:0, stale:0, stale_items:[]};
|
||||||
|
const reconCard = el("article","state-card "+(recon.stale ? "review" : "ok"));
|
||||||
|
reconCard.append(el("h3",null,"Reconciliation"));
|
||||||
|
addStateLine(reconCard,"accepted",recon.total);
|
||||||
|
addStateLine(reconCard,"ok",recon.ok);
|
||||||
|
addStateLine(reconCard,"stale",recon.stale);
|
||||||
|
if((recon.stale_items||[]).length){
|
||||||
|
const stale = el("div","path-list");
|
||||||
|
recon.stale_items.forEach(it=>{
|
||||||
|
const row = el("div","path-row missing");
|
||||||
|
row.append(el("b",null,"stale"));
|
||||||
|
row.append(el("span",null,it.kind+" "+it.target+" — "+(it.reason||"")));
|
||||||
|
stale.append(row);
|
||||||
|
});
|
||||||
|
reconCard.append(stale);
|
||||||
|
} else if(recon.total){
|
||||||
|
reconCard.append(el("div","fine","All acknowledgements still match live state."));
|
||||||
|
} else {
|
||||||
|
reconCard.append(el("div","fine","No acknowledged drift."));
|
||||||
|
}
|
||||||
|
grid.append(reconCard);
|
||||||
|
|
||||||
box.append(grid);
|
box.append(grid);
|
||||||
}
|
}
|
||||||
function renderRules(){
|
function renderRules(){
|
||||||
|
|
|
||||||
|
|
@ -421,11 +421,12 @@ def integrity_report(cfg: Config, status: dict | None = None,
|
||||||
|
|
||||||
|
|
||||||
def _reconciliation_summary(cfg: Config) -> dict:
|
def _reconciliation_summary(cfg: Config) -> dict:
|
||||||
"""Acknowledged-drift counts for the dashboard. Read-only: this evaluates
|
"""Acknowledged-drift counts for the dashboard. Read-only: it evaluates TTL
|
||||||
TTL expiry and persisted status, but does not run live FIM/package scans
|
expiry and persisted status in memory but never writes the store
|
||||||
from the web request path (consistent with the rest of integrity_report)."""
|
(``persist=False``), and does not run live FIM/package scans from the web
|
||||||
|
request path (consistent with the rest of integrity_report)."""
|
||||||
from . import reconcile
|
from . import reconcile
|
||||||
records = reconcile.ReconcileStore.load(cfg).list()
|
records = reconcile.ReconcileStore.load(cfg).list(persist=False)
|
||||||
stale = [r for r in records if r.status == "stale"]
|
stale = [r for r in records if r.status == "stale"]
|
||||||
return {
|
return {
|
||||||
"total": len(records),
|
"total": len(records),
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,20 @@ class WebReconciliationBlockTest(unittest.TestCase):
|
||||||
self.assertEqual(recon["stale"], 1)
|
self.assertEqual(recon["stale"], 1)
|
||||||
self.assertEqual(report["checks"]["reconciliation"], "review")
|
self.assertEqual(report["checks"]["reconciliation"], "review")
|
||||||
|
|
||||||
|
def test_integrity_report_never_writes_store(self):
|
||||||
|
# The dashboard is read-only: rendering the integrity view must not
|
||||||
|
# persist a TTL-lapsed ack, even though it now reports it as stale.
|
||||||
|
past = reconcile._utcnow() - __import__("datetime").timedelta(days=1)
|
||||||
|
store = reconcile.ReconcileStore.load(self.cfg)
|
||||||
|
store.accept("listener", "2/b", {"key": "2/b"}, "expired", "luna",
|
||||||
|
expires_at=past) # status persisted as "ok"
|
||||||
|
reconcile.ReconcileStore.invalidate_cache()
|
||||||
|
path = reconcile.ReconcileStore.store_path(self.cfg)
|
||||||
|
before = path.read_bytes()
|
||||||
|
web.integrity_report(self.cfg)
|
||||||
|
self.assertEqual(path.read_bytes(), before,
|
||||||
|
"integrity_report wrote to the reconciliation store")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue