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
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:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user