Files
felhom.eu/scripts/test_due_checks_gate.py
T
admin 0a5e9b14dc
gates / gates (push) Successful in 14s
due-checks gate (R-341), floor raise recorded (R-343), snapshot coverage (R-342)
PART 1+2 — dated checks stop being wishes. R-341 booked two measurements as
prose in a register row; nothing read those dates and nothing would have
objected when they passed. The dates now live in a DUE-CHECKS block INSIDE
OPEN-ITEMS.md (inside, so no sidecar can drift from it) and a new gate reads
them. Registered as #10 in repo_gates.py, --fast, so it runs in BOTH the
pre-push hook and CI.

  exit 0  nothing due (prints pending count + nearest date; empty block too)
  exit 1  a row is due/overdue (due <= today, UTC -- due TODAY counts), or a
          row names an item with no R-row
  exit 2  block absent/duplicated/unparseable -- INCONCLUSIVE, never 0

It REFUSES rather than warns, and its docstring states the limitation: it is
NOT a scheduler, it fires on the next push, not on the date.

37 tests. BOTH red-proofs run and reverted -- and the first one earned its
keep by catching a hollow assertion of MINE rather than confirming the gate:
flipping <= to < left a due-today row in neither bucket, min() raised on an
empty list, and the TRACEBACK exited 1, so "rc == 1" passed while the
boundary was wrong. An exit code cannot tell a verdict from a crash. The test
now asserts the conviction banner and the absence of a traceback, and the gate
returns 2 rather than crashing if that partition breaks again.

PART 3 — the floor raise, and the premise was WRONG. Read back from the store
(not the form): min_controller_version = 0.216.0 @ 12:36:58Z, zero
per-customer overrides, no "managed floor HELD" line. But read 5 shows the
raise was NOT a no-op: demo-felhom had been on 0.214.0 since 12 Aug and
auto-updated 0.214.0 -> 0.216.0 at 12:37:07Z -- NINE SECONDS after the save,
exactly the immediate action publish-train rule 2 documents. No error events
followed; it restarted clean.

R-343 is therefore filed OPEN, not CLOSED: the closing condition was all five
reads clean and no directive served. It went well, but a record calling it
inert when it moved a customer box is what misleads the next reader. The row
also states why the floor was behind -- rule 2 policy, not drift, earned by
the 2026-07-11 skew onto Peti's box -- and cites ResolveManagedFloor
(store.go:2068) plus the two build-felhom-iso.sh facts (build-time at :267,
fails open at :78-82) rather than asserting them.

Two boxes are below the floor and neither reports: drill-r50 (blocked,
powered off) and peti-felhom (host row deleted). peti-felhom was NOT
contacted -- its row records that a report from a deleted host 401s and is
not persisted, so the raise cannot reach it.

PART 4 — R-342 filed READY, quoting stop2-snapshot.txt verbatim: Hetzner
server snapshot 421440873 covers /dev/sda only; /mnt/pbs-datastore is a
separate Volume that snapshots exclude, so a rollback restores software state
and NOT the datastore. Fine for that upgrade; the safeguard for any future
procedure that could touch the datastore does not exist and is Viktor's call.

Also: CLAUDE.md's gate list named 6 of 10 registered gates -- completed
rather than appending a 7th to a wrong list (124 -> 128 effective, ceiling
200). Capability map deliberately unchanged; no row cites a floor or golden
version. repo_gates.py fully green, 10/10.
2026-08-18 15:16:51 +02:00

230 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""Fixture tests for due_checks_gate.py.
Run: python3 scripts/test_due_checks_gate.py
Every test asserts the EFFECT — the exit code AND that the message names the item and the reason —
not merely that the gate ran. A gate that refuses a push without saying WHICH check is overdue sends
the reader back to the register to guess, which is the state this gate exists to end.
Group G drives `repo_gates.py` itself and asserts the gate appears in the runner's output. It
deliberately does NOT grep repo_gates.py's source: a commented-out registration still contains the
string, so a source grep would pass on exactly the inert-seam failure this project has shipped
before. Only running the runner proves the gate is wired.
Fixtures are temp files; nothing here touches the real register.
"""
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
GATE = os.path.join(HERE, "due_checks_gate.py")
RUNNER = os.path.join(HERE, "repo_gates.py")
PASSED = []
FAILED = []
def check(name, cond, detail=""):
(PASSED if cond else FAILED).append(name + ((" — " + detail) if detail else ""))
print((" PASS " if cond else " FAIL ") + name + ((" " + detail) if detail else ""))
def run_gate(register_path, today="2026-08-20"):
env = dict(os.environ, FELHOM_GATE_TODAY=today)
p = subprocess.run([sys.executable, GATE, register_path],
capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
HEADER = "| item | due (UTC) | what to measure |\n|---|---|---|\n"
def make_register(tmp, rows_block, item_rows=("R-341",), markers=1, name="OPEN-ITEMS.md"):
"""Build a fixture register: some R-rows, then `markers` copies of the delimited block."""
path = os.path.join(tmp, name)
body = ["# OPEN-ITEMS (fixture)", ""]
for it in item_rows:
body.append("| **%s** | some description | WATCHING | — | do a thing | CC |" % it)
body.append("")
for _ in range(markers):
body.append("<!-- DUE-CHECKS-BEGIN — machine-readable. Parsed by scripts/due_checks_gate.py.")
body.append(" One row per dated check. Dates are UTC. -->")
body.append(rows_block.rstrip("\n"))
body.append("<!-- DUE-CHECKS-END -->")
body.append("")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(body) + "\n")
return path
# ── Group A — overdue refuses the push ───────────────────────────────────────────────────────
def test_a_overdue_exits_1_and_names_item_and_days():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n")
rc, out = run_gate(reg, today="2026-08-20")
check("A: overdue exits 1", rc == 1, "rc=%d" % rc)
check("A: names the item", "R-341" in out)
check("A: names the due date", "2026-08-19" in out)
check("A: says how many days overdue", "1 day(s) OVERDUE" in out)
check("A: points at the R-row for the command", "R-341 row of" in out)
# ── Group B — a future check is silent but not mute ──────────────────────────────────────────
def test_b_future_exits_0_and_still_reports_what_it_saw():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd count + split |\n")
rc, out = run_gate(reg, today="2026-08-20")
check("B: future exits 0", rc == 0, "rc=%d" % rc)
check("B: prints the pending count", "1 dated check(s) pending" in out)
check("B: prints the nearest due date", "2026-08-25" in out)
check("B: a passing run is not mute", "nearest:" in out)
# ── Group C — due exactly today counts as due ────────────────────────────────────────────────
def test_c_due_today_is_due():
"""The boundary is `due <= today`. Assert the CONVICTION, not merely a non-zero exit.
The first version of this test asserted only `rc == 1`, and the red-proof caught it being
hollow: flipping `<=` to `<` sent the row into neither bucket, `min()` raised on an empty
list, and the traceback exited 1 — so the test passed while the boundary was wrong. An exit
code alone cannot distinguish a verdict from a crash, which is why the banner is asserted and
a traceback is explicitly ruled out.
"""
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-19 | fd count + split |\n")
rc, out = run_gate(reg, today="2026-08-19")
check("C: due TODAY exits 1 (boundary is <=)", rc == 1, "rc=%d" % rc)
check("C: exits 1 as a VERDICT, not a traceback", "DUE-CHECKS GATE FAILED" in out)
check("C: did not crash", "Traceback" not in out)
check("C: says DUE TODAY rather than overdue", "DUE TODAY" in out)
def test_c_gate_never_ends_in_a_traceback():
"""Whatever the input, the gate exits 0/1/2 with a message — never a Python traceback."""
cases = {
"overdue": "| R-341 | 2026-08-19 | x |\n",
"future": "| R-341 | 2099-01-01 | x |\n",
"empty": "",
}
with tempfile.TemporaryDirectory() as tmp:
for name, rows in cases.items():
reg = make_register(tmp, HEADER + rows, name="OPEN-%s.md" % name)
rc, out = run_gate(reg, today="2026-08-20")
check("C: %s ends in a verdict not a traceback" % name,
"Traceback" not in out and rc in (0, 1, 2), "rc=%d" % rc)
# ── Group D — an orphaned row ────────────────────────────────────────────────────────────────
def test_d_orphaned_item_is_a_conviction():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-999 | 2026-09-30 | something |\n",
item_rows=("R-341",))
rc, out = run_gate(reg, today="2026-08-20")
check("D: orphan exits non-zero", rc != 0, "rc=%d" % rc)
check("D: names the orphaned item", "R-999" in out)
check("D: names the broken coupling", "no row in the register" in out
or "no `| **R-999** |` row found" in out)
def test_d_orphan_convicts_even_when_date_is_far_away():
"""The breakage is the missing row, not the timing — so a far-future orphan still convicts."""
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-888 | 2099-01-01 | far future |\n")
rc, out = run_gate(reg, today="2026-08-20")
check("D: far-future orphan still convicts", rc != 0, "rc=%d" % rc)
# ── Group E — malformed or missing block is INCONCLUSIVE, never a pass ───────────────────────
def test_e_missing_block_exits_2():
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "OPEN-ITEMS.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write("# OPEN-ITEMS (fixture)\n\n| **R-341** | d | s | — | a | CC |\n")
rc, out = run_gate(path, today="2026-08-20")
check("E: missing block exits 2 (NOT 0)", rc == 2, "rc=%d" % rc)
check("E: says inconclusive", "INCONCLUSIVE" in out)
check("E: explains why 0 would be wrong", "inert-seam" in out or "not a pass" in out)
def test_e_missing_register_exits_2():
with tempfile.TemporaryDirectory() as tmp:
rc, out = run_gate(os.path.join(tmp, "nope.md"), today="2026-08-20")
check("E: absent register exits 2", rc == 2, "rc=%d" % rc)
def test_e_malformed_row_exits_2_naming_the_line():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-341 | not-a-date | fd count |\n")
rc, out = run_gate(reg, today="2026-08-20")
check("E: bad date exits 2", rc == 2, "rc=%d" % rc)
check("E: names the offending value", "not-a-date" in out)
check("E: names the exact line", "line " in out)
def test_e_non_row_prose_in_block_exits_2_naming_the_line():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "this is prose, not a row\n")
rc, out = run_gate(reg, today="2026-08-20")
check("E: prose inside the block exits 2", rc == 2, "rc=%d" % rc)
check("E: quotes the offending line", "this is prose" in out)
def test_e_duplicated_block_exits_2():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER + "| R-341 | 2026-08-25 | fd |\n", markers=2)
rc, out = run_gate(reg, today="2026-08-20")
check("E: duplicated block exits 2", rc == 2, "rc=%d" % rc)
check("E: says two sources of truth", "more than once" in out)
# ── Group F — a well-formed empty block is a PASS, distinguishable from E ────────────────────
def test_f_empty_block_exits_0_with_its_own_message():
with tempfile.TemporaryDirectory() as tmp:
reg = make_register(tmp, HEADER)
rc, out = run_gate(reg, today="2026-08-20")
check("F: empty block exits 0", rc == 0, "rc=%d" % rc)
check("F: says no dated checks pending", "no dated checks pending" in out)
check("F: distinguishable from a missing block", "INCONCLUSIVE" not in out)
# ── Group G — registration, proven by DRIVING the runner (never a source grep) ────────────────
def test_g_gate_is_registered_in_the_runner():
"""A gate that passes its own tests and is not wired into repo_gates.py is inert.
This runs the runner and looks for the gate's label in its OUTPUT. A grep over
repo_gates.py's source would also match a commented-out line, which is precisely the
failure mode being guarded against.
"""
p = subprocess.run([sys.executable, RUNNER, "--fast"],
capture_output=True, text=True, cwd=ROOT)
out = p.stdout + p.stderr
check("G: runner names the due-checks gate in its output", "due-checks" in out)
check("G: runner reports a verdict line for it",
any(l.strip().startswith("due-checks") and ("OK" in l or "FAILED" in l
or "INCONCLUSIVE" in l)
for l in out.splitlines()))
check("G: runner invoked the gate script by name",
"due_checks_gate.py" in out)
def main():
print("test_due_checks_gate")
for fn in sorted((v for k, v in globals().items() if k.startswith("test_")),
key=lambda f: f.__name__):
fn()
print("")
print("passed: %d failed: %d" % (len(PASSED), len(FAILED)))
if FAILED:
print("")
for f in FAILED:
print(" FAILED: %s" % f)
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())