v0.228.0 — the off-site check reads the data; the debug page stops lying (R-399 + R-400)
gates / gates (push) Successful in 12s

R-399: monitoring.integrity.read_data_subset defaults to 100%. A pack damaged
without changing its size made plain `restic check` report "no errors were found"
on demo-hp 2026-08-30; every read-data form caught it. Cost on that 134 MB store:
35.0s structure vs 39.2s at 100%. "off" (any case) is the off token; empty means
not-configured, therefore the default; a malformed value falls back to the DEFAULT,
never to structure. A completed check over 5 minutes logs a WARN naming the
duration, the depth and R-401 — operator log only, no hub event, no depth change.
The depth is now recorded with the verdict (LastIntegrityDepth; empty = NOT
RECORDED, never "structure").

R-400: 24 debug-page references, 17 dispatched, 7 dead — three of which fetched on
page LOAD, so those panels were permanently blank. backup/crossdrive implemented;
backup/infra, hub/infra-push, dr/infra-status, storage/watchdog-status and both
storage/simulate-* deleted with their panels and JavaScript.
scripts/debug_route_gate.py fails in both directions and is registered after the
seven were resolved. 18 referenced, 18 dispatched, none orphaned.

Corrections: the dead-field warning in report/types.go said the controller runs no
integrity check and the notifiers are called from nowhere — both false since
v0.227.0. controller.yaml.example gains its missing integrity: block.
integrityCheckTimeout's "ships OFF" comment rewritten.
This commit is contained in:
2026-08-31 10:24:29 +02:00
parent 300d7e87d7
commit 3c49dc8ea4
23 changed files with 1144 additions and 175 deletions
+6 -1
View File
@@ -15,7 +15,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
5. app-row-dedup no duplicated app-row markup in the dashboard templates
6. mojibake no double-encoded UTF-8 in Hungarian copy
7. docker-v every `docker … -v` mount is a named volume or a proven host path
8. reuse-refs every path cited by this repo's REUSE.md still resolves
8. debug-routes every debug-page control resolves to a handler, and back (R-400)
9. reuse-refs every path cited by this repo's REUSE.md still resolves
WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of leg (b)).
@@ -62,6 +63,10 @@ GATES = [
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True),
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True),
("retrieval-promise", os.path.join(SCRIPTS, "retrieval_promise_gate.py"), [], True),
# R-400 — every debug-page control resolves to a handler, and every handler is reachable.
# Registered AFTER the seven dead controls were implemented or deleted: a registered-but-failing
# gate refuses every push, so the order matters here exactly as it did for instructions_gate.
("debug-routes", os.path.join(SCRIPTS, "debug_route_gate.py"), [], True),
("reuse-refs", SHARED_REUSE, [REPO], True),
("instructions", SHARED_INSTRUCTIONS, [REPO], True),
# R-389 — a REPORT.md observation with no register row behind it. Fast: stdlib file reads.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Debug-route gate (R-400) — every control on the debug page must resolve to a handler, and every
handler must be reachable from the page.
WHY IT EXISTS. On 2026-08-31 the shipped debug page referenced 24 `/api/debug/...` addresses and the
dispatcher answered 17. Seven controls did nothing, and three of those seven were not buttons at all:
they fetch on page LOAD, so whole panels had been permanently empty and nobody had to click anything
to be misled. This is the page an operator opens when something is already wrong.
BOTH DIRECTIONS FAIL. A reference with no case is a dead control. A case with no reference is a
handler nothing reaches — the same defect mirrored, and the shape this project has now shipped eight
times. Neither is a warning here.
DELIBERATELY TEN LINES OF LOGIC. Two lists and a difference. Its value is that it cannot rot: a
cleverer gate that understood routing would need maintaining, and an unmaintained gate is how the
class hides in the first place. The dispatcher's EXACT-match switch with a NotFound default is what
made the original defect visible, and this gate assumes exactly that shape — do not make either clever.
Run from controller/: python3 scripts/debug_route_gate.py
"""
import io
import os
import re
import sys
TEMPLATE = os.path.join("internal", "web", "templates", "debug.html")
DISPATCH = os.path.join("internal", "web", "handler_debug.go")
REF_RE = re.compile(r"/api/debug/([A-Za-z0-9/_-]+)")
CASE_RE = re.compile(r'subpath\s*==\s*"([A-Za-z0-9/_-]+)"')
def read(path):
if not os.path.exists(path):
print("DEBUG ROUTE GATE INCONCLUSIVE: %s not found (run from controller/)" % path)
sys.exit(2)
return io.open(path, encoding="utf-8").read()
def main():
# Sets, not lists: the same address referenced by two controls is satisfied by one case (§8).
refs = set(REF_RE.findall(read(TEMPLATE)))
cases = set(CASE_RE.findall(read(DISPATCH)))
dead = sorted(refs - cases)
unreached = sorted(cases - refs)
for name in dead:
print("DEAD CONTROL %s references /api/debug/%s and %s has no case for it"
% (TEMPLATE, name, DISPATCH))
for name in unreached:
print("UNREACHED %s dispatches %r and %s never references it"
% (DISPATCH, name, TEMPLATE))
if dead or unreached:
print("DEBUG ROUTE GATE FAILED: %d dead control(s), %d unreached handler(s)"
% (len(dead), len(unreached)))
sys.exit(1)
print("debug route gate OK - %d referenced address(es), all dispatched, none orphaned"
% len(refs))
if __name__ == "__main__":
main()
@@ -30,6 +30,7 @@ FINGERPRINTS = [
("app-row-dedup", "app_row_dedup_gate: OK"),
("mojibake", "mojibake_gate: OK"),
("docker-v", "docker -v gate OK"),
("debug-routes", "debug route gate OK"),
("reuse-refs", "cited paths — exact"),
]
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tests for scripts/debug_route_gate.py (R-400).
Run from controller/: python3 scripts/test_debug_route_gate.py
WHY THE RED-PROOFS ARE THE POINT. A gate that has never been seen failing is a gate that has not been
shown to gate anything. This is the second time that sentence has earned its place in this project, so
both directions are proven by MUTATING a real copy of the tree and watching the gate convict.
"""
import io
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(SCRIPTS)
GATE = os.path.join(SCRIPTS, "debug_route_gate.py")
TEMPLATE = os.path.join("internal", "web", "templates", "debug.html")
DISPATCH = os.path.join("internal", "web", "handler_debug.go")
RUNNER = os.path.join(SCRIPTS, "controller_gates.py")
def run_gate(cwd):
p = subprocess.run([sys.executable, GATE], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
return p.returncode, p.stdout.decode("utf-8", "replace")
def sandbox():
"""A copy of just the two files the gate reads, in a throwaway tree.
Mutating the real tree and reverting is the version of this that leaves a broken repo behind when
an assertion fails mid-test.
"""
tmp = tempfile.mkdtemp(prefix="debugroutegate-")
for rel in (TEMPLATE, DISPATCH):
dst = os.path.join(tmp, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(os.path.join(CTRL, rel), dst)
return tmp
class DebugRouteGateTest(unittest.TestCase):
# C1 — the gate passes on the shipped tree.
def test_passes_on_the_shipped_tree(self):
rc, out = run_gate(CTRL)
self.assertEqual(rc, 0, out)
self.assertIn("debug route gate OK", out)
# C2 — Scenario G, the mandatory red-proof: a reference with no handler convicts, and is NAMED.
def test_fails_on_an_unwired_reference(self):
tmp = sandbox()
try:
path = os.path.join(tmp, TEMPLATE)
src = io.open(path, encoding="utf-8").read()
io.open(path, "w", encoding="utf-8").write(
src + '\n<!-- red-proof --><a href="/api/debug/storage/simulate-disconnect">x</a>\n')
rc, out = run_gate(tmp)
self.assertEqual(rc, 1, "a dead control did not convict the gate:\n" + out)
self.assertIn("storage/simulate-disconnect", out,
"the gate convicted without NAMING the reference:\n" + out)
finally:
shutil.rmtree(tmp)
# C3 — the mirror image: a handler nothing reaches is the same defect.
def test_fails_on_an_unreached_handler(self):
tmp = sandbox()
try:
path = os.path.join(tmp, DISPATCH)
src = io.open(path, encoding="utf-8").read()
marker = 'case subpath == "dump" && r.Method == http.MethodGet:'
self.assertIn(marker, src, "fixture drifted: the dispatch shape changed")
io.open(path, "w", encoding="utf-8").write(src.replace(
marker,
'case subpath == "ghost/handler" && r.Method == http.MethodGet:\n\t\ts.debugDump(w, r)\n\t' + marker,
1))
rc, out = run_gate(tmp)
self.assertEqual(rc, 1, "an unreached handler did not convict the gate:\n" + out)
self.assertIn("ghost/handler", out,
"the gate convicted without NAMING the handler:\n" + out)
finally:
shutil.rmtree(tmp)
# C4 — registration, read from the runner's own GATES table rather than by matching text.
# A commented-out row still contains the string; an entry in the parsed list does not.
def test_gate_is_registered_in_the_runner(self):
import ast
tree = ast.parse(io.open(RUNNER, encoding="utf-8").read())
labels = []
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == "GATES":
for elt in node.value.elts:
first = elt.elts[0]
labels.append(first.value if hasattr(first, "value") else first.s)
self.assertTrue(labels, "the runner's GATES table could not be parsed")
self.assertIn("debug-routes", labels,
"debug_route_gate.py exists but the runner never lists it — a gate nothing runs "
"is the inert seam this repo has shipped four times. Listed: %r" % (labels,))
if __name__ == "__main__":
unittest.main(verbosity=2)