steps/ per ladder step: gate rule 4, writer, backfill (8); R-653, R-656; decoy suite reads live pins (R-663)
gates / gates (push) Successful in 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 07:57:51 +02:00
parent 585a7cba22
commit 5ed599cd5f
16 changed files with 1150 additions and 22 deletions
+15
View File
@@ -19,6 +19,9 @@ WHAT IT CHECKS, per template that carries `update_ladder:` (format and field rul
3. the NEWEST entry's `to` is EXACTLY the compose's current image per service. This is the fact
that makes the rule hold without history: a compose moved without a new entry no longer
matches its ladder's head, whoever pushed it and however.
4. every entry but the newest carries its OWN definition at `steps/<step_key(to)>.yml`, whose
images per service are EXACTLY that entry's `to` (`09` §6.4 part 5: the box pins that file, one
step per press). The name alone is not the fact: the file's own `image:` lines are read.
A template WITHOUT a ladder passes here — it has never been moved since the gate existed, and its
first move is refused by the twin unless that move brings the first entry.
@@ -50,6 +53,18 @@ def check_app(app_dir):
for i in range(1, len(entries)):
if entries[i].get("from") != entries[i - 1].get("to"):
problems.append("entry %d's `from` is not entry %d's `to` — the ladder has a gap" % (i + 1, i))
for i, e in enumerate(entries[:-1]):
to = e.get("to")
if not isinstance(to, dict) or not to:
continue # already convicted by check_entry
sp = os.path.join(app_dir, ladder.step_file(to))
if not os.path.isfile(sp):
problems.append("entry %d of %d has no definition at %s — the box climbs one step at a time "
"and needs this step's own compose file" % (i + 1, len(entries), ladder.step_file(to)))
continue
got = ladder.images_in(open(sp, encoding="utf-8").read())
if got != to:
problems.append("%s names %s, but entry %d's `to` is %s" % (ladder.step_file(to), got, i + 1, to))
if entries:
current = ladder.images_in(open(comp, encoding="utf-8").read())
head = entries[-1].get("to")
+19
View File
@@ -30,8 +30,16 @@ AN ENTRY (all keys required unless marked):
backfilled (optional) "YYYY-MM-DD" — written by the backfill from an EXISTING record,
never by a new test; a new move may not carry it
STEP DEFINITIONS (`09` §6.4 part 5, controller v0.268.0): every entry but the NEWEST carries its own
complete compose file at `templates/<app>/steps/<step_key(to)>.yml` — the box climbs one step at a time
and pins exactly that file; the newest step's definition is the template's `docker-compose.yml`. The box's
catalog clone is `--depth 1`, so git history is not a place a box can read a step from, and the commit
that moved an image is not always the definition that works (romm 15f9ebf). `step_key` is computed the
SAME way by the controller (`stacks.StepKey`, pinned by TestLadder_StepKeyMatchesTheCatalog).
Every path that reads or writes the format is here, so the gate and the writer cannot disagree.
"""
import hashlib
import json
import re
@@ -191,3 +199,14 @@ def append_entry(felhom_text, e):
last = j
lines.insert(last + 1, line)
return "\n".join(lines) + "\n"
def step_key(to):
"""The 16-hex name of a step's definition: sha256 of `to` as canonical JSON (keys sorted, no spaces).
The controller computes the same string (stacks.StepKey)."""
return hashlib.sha256(json.dumps(to, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
def step_file(to):
"""The step definition's path RELATIVE to the template directory."""
return "steps/%s.yml" % step_key(to)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""steps_backfill.py — ONE-OFF (2026-09-24, `09` §6.4 part 5): write `steps/<step_key(to)>.yml` for every
ladder entry that is not the newest, from git history.
WHICH COMMIT, and why not "the commit of the step": the definition a box must pin for a step is the one
the catalog SERVED while that step was the head — the NEWEST commit whose compose images equal the
step's `to`, i.e. the step's images WITH every fix that flowed after they moved. The commit that MOVED
the image can be the broken one: romm's 15f9ebf (5.0.0 -> 5.3.0, 512M, four workers) OOM-looped on
demo-hp; the working definition is the same images under f4eb94f's template (768M, two workers).
Prints one line per step: app, entry, step key, the commit it came from. Never overwrites a step file.
"""
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ladder # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def git(*a):
return subprocess.run(["git", "-C", ROOT] + list(a), capture_output=True, text=True, check=True).stdout
def main():
wrote = 0
for app in sorted(os.listdir(os.path.join(ROOT, "templates"))):
d = os.path.join(ROOT, "templates", app)
fy = os.path.join(d, ".felhom.yml")
if not os.path.isfile(fy):
continue
entries, _, errs = ladder.parse(open(fy, encoding="utf-8").read())
if errs or len(entries) < 2:
continue
rel = "templates/%s/docker-compose.yml" % app
commits = git("log", "--format=%H", "--", rel).split() # newest first
for i, e in enumerate(entries[:-1]):
dst = os.path.join(d, ladder.step_file(e["to"]))
if os.path.exists(dst):
print("SKIP %-10s entry %d: %s exists" % (app, i + 1, ladder.step_file(e["to"])))
continue
found = None
for c in commits:
body = git("show", "%s:%s" % (c, rel))
if ladder.images_in(body) == e["to"]:
found = (c, body)
break # newest first: the last definition served for this step
if not found:
print("MISS %-10s entry %d: no commit's compose names %s" % (app, i + 1, e["to"]))
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
open(dst, "w", encoding="utf-8").write(found[1])
print("WROTE %-10s entry %d/%d -> %s from %s (%s)" % (app, i + 1, len(entries), ladder.step_file(e["to"]),
found[0][:12], git("log", "-1", "--format=%s", found[0]).strip()[:70]))
wrote += 1
print("steps_backfill: %d step definition(s) written" % wrote)
return 0
if __name__ == "__main__":
sys.exit(main())
+70 -16
View File
@@ -94,6 +94,7 @@ def commit(clone, msg):
def reset(clone):
sh(["git", "reset", "-q", "--hard", "HEAD"], cwd=clone)
sh(["git", "clean", "-fdq"], cwd=clone) # a case may ADD a file (a steps/ definition)
def case(name, clone, edits, expect_rc, must_contain=(), gate="check-engine-major.py"):
@@ -283,6 +284,25 @@ def swap_image(service, frm, to):
def cur_image(clone, relpath, service):
"""The service's current image in the clone — read, never typed (R-663)."""
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import ladder as _l
return _l.images_in(io.open(os.path.join(clone, relpath), encoding="utf-8").read())[service]
def strip_ladder(t):
"""The template WITHOUT its update_ladder block (and the header comment the writer puts above it).
The writer appends the block at the END of the file (ladder.append_entry), so everything from its
first line on goes — a case then builds exactly the ladder it describes, whatever the live catalog
has recorded since (R-663)."""
lines = t.splitlines()
for i, l in enumerate(lines):
if l.startswith("# update_ladder") or l.startswith("update_ladder:"):
return "\n".join(lines[:i]).rstrip("\n") + "\n"
return t
# ── test record (09 §3 decision 13) ────────────────────────────────────────────────────────────
TR_D1 = "sha256:" + "a" * 64
TR_D2 = "sha256:" + "b" * 64
@@ -299,8 +319,12 @@ def tr_entry(frm, to, digest, verdict="proven", peak=41.0, tight=False, **extra)
def tr_append(line, header=True):
"""Append one entry line. A template that already HAS a ladder (the writer puts it at the end of
the file) gets the line appended to it; one without gets the block (R-663: navidrome gained a
ladder on 2026-09-23 night, and a second `update_ladder:` key is its own conviction)."""
def _fn(t):
block = ("\nupdate_ladder:\n" if header else "") + line + "\n"
has = any(l.startswith("update_ladder:") for l in t.splitlines())
block = ("\nupdate_ladder:\n" if header and not has else "") + line + "\n"
return t.rstrip("\n") + "\n" + block
return _fn
@@ -349,7 +373,10 @@ def case_tr_static(name, clone, edits, expect_rc, must_contain=(), apps=("navidr
def test_record_cases(clone):
NC, NF = "templates/navidrome/docker-compose.yml", "templates/navidrome/.felhom.yml"
old, new = "deluan/navidrome:0.64.0", "deluan/navidrome:0.64.1"
# READ, never typed (R-663): the live pin moves with every proven step.
old = cur_image(clone, NC, "navidrome")
new = "deluan/navidrome:0.99.1"
prev = "deluan/navidrome:0.1.0" # an invented older step, for the static cases
frm, to = {"navidrome": old}, {"navidrome": new}
move = (NC, swap_image("navidrome", old, new))
good = tr_entry(frm, to, {"navidrome": TR_D1})
@@ -367,7 +394,7 @@ def test_record_cases(clone):
case_tr_move("INCONCLUSIVE: the registry cannot be asked", clone,
[move, (NF, tr_append(good))], 2, ("could not be asked",), {})
case_tr_move("DECOY: the entry only in a COMMENT under update_ladder", clone,
[move, (NF, lambda t: t.rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1,
[move, (NF, lambda t: strip_ladder(t).rstrip("\n") + "\nupdate_ladder:\n # " + good.strip() + "\n")], 1,
("holds no entry",), table)
case_tr_move("DECOY: the entry only in README.md", clone,
[move, ("README.md", lambda t: t + "\n" + good + "\n")], 1,
@@ -385,21 +412,42 @@ def test_record_cases(clone):
case_tr_move("DECOY: a ref moves in a compose COMMENT only", clone,
[(NC, lambda t: t + "\n# was: deluan/navidrome:0.63.2\n")], 0, (), table)
print("-- test-record (static): the newest step IS the compose")
case_tr_static("GENUINE: a ladder whose head is the compose", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))], 0)
def ladder_of(*lines):
"""Replace the template's ladder with exactly these entry lines."""
return lambda t: tr_append("\n".join(lines))(strip_ladder(t))
head = tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1})
case_tr_static("GENUINE: a ladder whose head is the compose", clone, [(NF, ladder_of(head))], 0)
case_tr_static("FACT: the compose moved past the ladder's head", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}))),
move], 1, ("not the ladder's newest step",))
[(NF, ladder_of(head)), move], 1, ("not the ladder's newest step",))
case_tr_static("FACT: a line that is not one JSON entry", clone,
[(NF, lambda t: t + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",))
[(NF, lambda t: strip_ladder(t) + "\nupdate_ladder:\n - from: x\n")], 1, ("not a one-line JSON entry",))
case_tr_static("FACT: a gap between steps", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.62.0"}, {"navidrome": "deluan/navidrome:0.63.0"}, {"navidrome": TR_D1})
+ "\n" + tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1})))],
[(NF, ladder_of(tr_entry({"navidrome": "deluan/navidrome:0.0.1"}, {"navidrome": "deluan/navidrome:0.0.2"}, {"navidrome": TR_D1}), head))],
1, ("the ladder has a gap",))
case_tr_static("FACT: a failed verdict sits in the ladder", clone,
[(NF, tr_append(tr_entry({"navidrome": "deluan/navidrome:0.63.2"}, frm, {"navidrome": TR_D1}, verdict="failed")))],
[(NF, ladder_of(tr_entry({"navidrome": prev}, frm, {"navidrome": TR_D1}, verdict="failed")))],
1, ("not allowed in a ladder",))
# ── `09` §6.4 part 5 (v0.268.0 on the box): every step but the newest carries its OWN definition
# at steps/<StepKey(to)>.yml — the box climbs one step at a time and pins that file. ──────────
print("-- test-record (static): every intermediate step carries its own definition")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import ladder as _l
mid = {"navidrome": "deluan/navidrome:0.2.0"}
two = ladder_of(tr_entry({"navidrome": prev}, mid, {"navidrome": TR_D1}),
tr_entry(mid, frm, {"navidrome": TR_D1}))
step_rel = "templates/navidrome/" + _l.step_file(mid)
step_body = lambda t: swap_image("navidrome", old, mid["navidrome"])(io.open(os.path.join(clone, NC), encoding="utf-8").read())
case_tr_static("FACT: a two-step ladder with NO steps/ file for the first step", clone,
[(NF, two)], 1, ("has no definition",))
case_tr_static("GENUINE: a two-step ladder whose first step carries its definition", clone,
[(NF, two), (step_rel, step_body)], 0)
case_tr_static("DECOY: the steps/ file has the right NAME and names the head's image", clone,
[(NF, two), (step_rel, lambda t: io.open(os.path.join(clone, NC), encoding="utf-8").read())],
1, ("names",))
case_tr_static("DECOY: the step's definition sits beside the template under another name", clone,
[(NF, two), ("templates/navidrome/steps/0.2.0.yml", step_body)], 1, ("has no definition",))
def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate):
@@ -409,6 +457,12 @@ def main():
try:
KIMAI = "templates/kimai/docker-compose.yml"
DOCMOST = "templates/docmost/docker-compose.yml"
# The engine ref is READ from the clone, not typed: the night of 2026-09-23 moved kimai-db
# 11.6 -> 11.8 through its own test record, and a literal here broke every case below
# ("fixture drifted") without a single gate changing. R-663.
KDB = cur_image(clone, KIMAI, "kimai-db")
if not KDB.startswith("mariadb:11."):
raise SystemExit("kimai-db is %s — the cases below assume a MariaDB 11 line; fixture drifted" % KDB)
# ── THE FACTS: these must be refused ─────────────────────────────────────────────────
out = case("FACT: docmost-postgres 16-alpine -> 17-alpine bundled with the app bump", clone,
@@ -424,20 +478,20 @@ def main():
# bookstack 0b73e5e shape — two migrations behind one edge — and stays refused.
case("FACT: kimai-db 11.6 -> 12.3 BUNDLED with the kimai app bump", clone,
[(KIMAI, lambda t: swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")(
swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3")(t)))],
swap_image("kimai-db", KDB, "mariadb:12.3")(t)))],
expect_rc=1, must_contain=("IN THE SAME COMMIT as kimai", "OWN EDGE", "R-450"))
case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:lts"))],
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:lts"))],
expect_rc=2, must_contain=("INCONCLUSIVE",))
# ── THE GENUINE ARTICLES: these must pass ────────────────────────────────────────────
case("GENUINE: kimai-db mariadb:11.6 -> 11.8 (within major)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:11.8"))],
case("GENUINE: kimai-db mariadb:11.x -> 11.99 (within major)", clone,
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:11.99"))],
expect_rc=0, must_contain=("engine-major gate OK",))
# R-469: the LIFT itself. A MariaDB major ALONE in its template is now permitted, and the
# gate says so by name rather than passing in silence.
case("GENUINE: kimai-db mariadb:11.6 -> 12.3 ALONE (the R-469 lift)", clone,
[(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))],
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:12.3"))],
expect_rc=0, must_contain=("ALLOWED", "kimai-db", "mariadb 11 -> 12", "R-469"))
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
+34 -5
View File
@@ -28,7 +28,14 @@ spec.loader.exec_module(ut)
D = "sha256:" + "c" * 64
def bench(verdict="proven", peak=0.41, marks=(), frm="0.64.0", to="0.64.1"):
# READ, never typed (R-663): navidrome moved 0.64.0 -> 0.64.1 on 2026-09-23 night and a literal here
# broke two of these tests without the writer changing.
LIVE = ladder.images_in(open(os.path.join(ROOT, "templates", "navidrome", "docker-compose.yml")).read())["navidrome"].split(":")[1]
NEXT = LIVE + "-next"
AFTER = LIVE + "-after"
def bench(verdict="proven", peak=0.41, marks=(), frm=LIVE, to=NEXT):
return {"harness_version": 3, "app": "navidrome", "verdict": verdict,
"from": {"navidrome": "deluan/navidrome:" + frm}, "to": {"navidrome": "deluan/navidrome:" + to},
"measured_at": "2026-09-23T22:00:00Z", "marks": list(marks),
@@ -41,11 +48,12 @@ class WriterTest(unittest.TestCase):
shutil.copytree(os.path.join(ROOT, "templates", "navidrome"),
os.path.join(self.tmp, "templates", "navidrome"))
self.fy = os.path.join(self.tmp, "templates", "navidrome", ".felhom.yml")
# start from a template WITHOUT a ladder, at 0.64.0 (the live pin tonight)
# start from a template WITHOUT a ladder, at the live pin
text = open(self.fy).read()
if "update_ladder:" in text:
text = text[:text.index("\n# update_ladder")] + "\n"
open(self.fy, "w").write(text)
shutil.rmtree(os.path.join(self.tmp, "templates", "navidrome", "steps"), ignore_errors=True)
self._orig = image_digest.resolve
image_digest.resolve = lambda ref: (D, None)
@@ -67,11 +75,11 @@ class WriterTest(unittest.TestCase):
self.assertEqual(rc, 0, out)
entries, _, errs = ladder.parse(open(self.fy).read())
self.assertEqual(errs, [])
self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:0.64.1"})
self.assertEqual(entries[-1]["to"], {"navidrome": "deluan/navidrome:" + NEXT})
self.assertEqual(entries[-1]["memory_peak_pct"], 41.0) # a PERCENT, from the watch's fraction
self.assertEqual(entries[-1]["digest"], {"navidrome": D})
comp = open(os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")).read()
self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:0.64.1"})
self.assertEqual(ladder.images_in(comp), {"navidrome": "deluan/navidrome:" + NEXT})
import subprocess
r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp,
"navidrome"], capture_output=True, text=True)
@@ -88,7 +96,7 @@ class WriterTest(unittest.TestCase):
self.assertNotIn("update_ladder:", open(self.fy).read())
def test_refuses_when_the_template_is_not_at_from(self):
rc, out = self.run_writer(bench(frm="0.63.2"))
rc, out = self.run_writer(bench(frm="0.0.1"))
self.assertEqual(rc, 1)
self.assertIn("the template is at", out)
@@ -98,6 +106,27 @@ class WriterTest(unittest.TestCase):
e = ladder.parse(open(self.fy).read())[0][-1]
self.assertEqual(e["marks"], {"files_may_change": True, "needs_person": None, "memory_tight": True})
def test_a_second_step_keeps_the_first_steps_definition(self):
"""`09` §6.4 part 5: writing step 2 turns step 1 into an intermediate step — its OWN definition
(the compose as it stood, fixes included) is kept at steps/<key>.yml, and the gate accepts the
two-step ladder. RED-PROOF (REPORT.md): drop the STEP block in write_ladder — the gate refuses
at "has no definition"."""
rc, out = self.run_writer(bench())
self.assertEqual(rc, 0, out)
comp_p = os.path.join(self.tmp, "templates", "navidrome", "docker-compose.yml")
# a fix flows into the step-1 definition after it was published (the romm f4eb94f shape)
open(comp_p, "a").write("# a fix that flowed after step 1\n")
at_step1 = open(comp_p).read()
rc, out = self.run_writer(bench(frm=NEXT, to=AFTER))
self.assertEqual(rc, 0, out)
sp = os.path.join(self.tmp, "templates", "navidrome", ladder.step_file({"navidrome": "deluan/navidrome:" + NEXT}))
self.assertTrue(os.path.isfile(sp), out)
self.assertEqual(open(sp).read(), at_step1, "the step file must be the definition AS SERVED, fixes included")
import subprocess
r = subprocess.run([sys.executable, os.path.join(HERE, "check-test-record.py"), "--root", self.tmp,
"navidrome"], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout)
if __name__ == "__main__":
unittest.main(verbosity=2)
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_upgrade_bench.py — the test bench's two night-of-2026-09-23 faults (R-653, R-656). No Docker.
python3 scripts/test_upgrade_bench.py
"""
import importlib.util
import os
import shutil
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("upgrade_test_mod", os.path.join(HERE, "upgrade-test.py"))
ut = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ut)
class LoadVerdict(unittest.TestCase):
"""R-653. RED-PROOF (REPORT.md): make load_verdict always return "reached" — the ghost case fails."""
def test_every_request_errored_is_inconclusive(self):
# ghost's first watch, 2026-09-23 night: 11 797 requests, all `err`
self.assertEqual(ut.load_verdict(11797, {"err": 11797}), "inconclusive")
def test_answers_of_any_code_are_the_app_answering(self):
self.assertEqual(ut.load_verdict(11429, {"200": 5712, "401": 5717}), "reached")
self.assertEqual(ut.load_verdict(10, {"302": 5, "err": 5}), "reached")
def test_under_half_is_inconclusive_and_none_is_inconclusive(self):
self.assertEqual(ut.load_verdict(10, {"200": 4, "err": 6}), "inconclusive")
self.assertEqual(ut.load_verdict(0, {}), "inconclusive")
class ClearScratch(unittest.TestCase):
"""R-656. RED-PROOF (REPORT.md): make clear_scratch_folders return [] without removing — the first
test fails with the last run's config still there."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="bench-scratch-")
self.hdd = os.path.join(self.root, "hdd")
self.env = {"HDD_PATH": self.hdd, "USERDATA_PATH": self.hdd + "/userdata"}
self.said = []
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def plant(self, rel):
p = os.path.join(self.hdd, rel, "config", "config.php")
os.makedirs(os.path.dirname(p), exist_ok=True)
open(p, "w").write("last run")
return p
def test_the_apps_own_folders_are_cleared_and_said(self):
mine = self.plant("appdata/nextcloud")
other = self.plant("appdata/immich")
comp = "services:\n nextcloud:\n volumes:\n - ${HDD_PATH}/appdata/nextcloud:/var/www/html\n"
got = ut.clear_scratch_folders(comp, self.env, self.said.append)
self.assertFalse(os.path.exists(mine), "the last run's files are still there")
self.assertTrue(os.path.exists(other), "another app's folder was touched")
self.assertEqual(len(got), 1)
self.assertTrue(any("cleared before FROM" in s for s in self.said))
def test_never_a_bare_root_never_outside(self):
keep = self.plant("appdata/x")
comp = (" volumes:\n - ${HDD_PATH}:/data\n - ${HDD_PATH}/:/d2\n"
" - ${HDD_PATH}/../escape:/e\n")
got = ut.clear_scratch_folders(comp, self.env, self.said.append)
self.assertEqual(got, [])
self.assertTrue(os.path.exists(keep))
if __name__ == "__main__":
unittest.main(verbosity=2)
+55 -1
View File
@@ -465,12 +465,50 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
tight = [n for n, p in per.items() if _tight_pct(p) is not None and _tight_pct(p) > MEMORY_TIGHT]
rec = {"soak_s": round(time.time() - t0, 1), "requested_s": seconds, "requests": hits["n"],
"codes": hits["codes"], "first_kill": first_bad, "containers": per,
"unmeasured": [n for n, p in per.items() if not p["measured"]]}
"unmeasured": [n for n, p in per.items() if not p["measured"]],
"load": load_verdict(hits["n"], hits["codes"])}
marks = ["memory_tight"] if tight and not killed else []
say(f"memory watch: killed={killed} tight={tight} requests={hits['n']} codes={hits['codes']}")
return rec, killed, marks
def load_verdict(requests: int, codes: dict) -> str:
"""R-653: did the watch's load REACH the app? `reached` when at least half its requests got any HTTP
answer (a 401 or a 30x is the app answering); `inconclusive` otherwise. Measured 2026-09-23 night:
ghost's and nextcloud's first ten-minute watches sent 11 797 and 9 427 requests and EVERY one was
`err` — memory measured, app idle — and the harness wrote `proven` over it."""
if requests <= 0:
return "inconclusive"
answered = sum(n for c, n in codes.items() if c != "err")
return "reached" if answered * 2 >= requests else "inconclusive"
def clear_scratch_folders(compose_text: str, env: dict, say) -> list:
"""R-656: before FROM, remove the app's OWN scratch drive folders — every `${HDD_PATH}/…`,
`${USERDATA_PATH}/…` and `${IMPORT_PATH}/…` bind the compose names — so a re-run of the same app does
not start on the last run's files (nextcloud's second run never installed, 2026-09-23 night: `occ
status: installed: false` over the first run's config/). `compose down -v` removes named volumes, not
bind-mounted host folders. NEVER a bare root, never a path outside the scratch roots. Returns what was
removed, and says so either way."""
roots = {k: env.get(k) for k in ("HDD_PATH", "USERDATA_PATH", "IMPORT_PATH") if env.get(k)}
removed = []
for var, rel in re.findall(r"\$\{(HDD_PATH|USERDATA_PATH|IMPORT_PATH)\}(/[^:\s\"']*)", compose_text):
root = roots.get(var)
if not root:
continue
rel = rel.strip("/")
target = os.path.realpath(os.path.join(root, rel))
safe_root = os.path.realpath(root)
if not rel or target == safe_root or not target.startswith(safe_root + os.sep):
say(f"scratch folder NOT cleared (outside or equal to {var}={root}): {rel!r}")
continue
if os.path.exists(target):
shutil.rmtree(target, ignore_errors=False)
removed.append(target)
say(f"scratch drive folders cleared before FROM (R-656): {removed or 'none existed'}")
return removed
MIGRATION_RE = re.compile(
r"migrat|upgrad|schema|alter table|CREATE TABLE|InnoDB: Upgrad|mysql_upgrade|"
r"mariadb-upgrade|Running .* migration|Applying|db:migrate",
@@ -558,6 +596,7 @@ def run_edge(edge_id: str) -> dict:
try:
# --- 1. FROM ---
rec["scratch_cleared"] = clear_scratch_folders(compose_text, env, say)
say(f"{edge_id}: deploying {app} at FROM {e['frm']}")
render(app, e["frm"], workdir, env, e.get("template"))
up = compose(workdir, project, "up", "-d")
@@ -652,6 +691,11 @@ def run_edge(edge_id: str) -> dict:
if killed:
rec["verdict"] = "failed"
say("VERDICT -> failed: the new version was OOM-killed or restarted under light load")
elif mem.get("load") != "reached":
rec["verdict"] = "inconclusive"
rec["abort_detail"] = (f"memory watch: fewer than half of its {mem.get('requests')} requests reached "
f"the app ({mem.get('codes')}) — memory measured on an idle app (R-653)")
say("VERDICT -> inconclusive: " + rec["abort_detail"])
# --- 6. the ABORT ---
say(f"{edge_id}: ABORT — putting the FROM images back")
@@ -778,6 +822,16 @@ def write_ladder(argv) -> int:
if probs:
print(f"REFUSED {app}: the entry would not be well-formed: {probs}")
return 1
# `09` §6.4 part 5: the step being SUPERSEDED keeps its own definition. When the ladder's head is the
# compose as it stands, that compose — the head's images with every fix that flowed since — becomes
# steps/<step_key(head.to)>.yml, the file a box one step behind will pin (check-test-record.py rule 4).
prior, _, _ = ladder.parse(fy_p.read_text())
if prior and prior[-1].get("to") == cur:
sp = tdir / ladder.step_file(cur)
if not sp.exists():
sp.parent.mkdir(parents=True, exist_ok=True)
sp.write_text(comp)
print(f"STEP {app}: the superseded step {cur} keeps its definition at {ladder.step_file(cur)}")
# move the compose, per service, on that service's own image: line
out, svc = [], None
for line in comp.splitlines():