#!/usr/bin/env python3 # -*- coding: utf-8 -*- """test_ladder_writer.py — the ONLY ladder writer (`upgrade-test.py --write-ladder`) writes what the gate accepts, and refuses what it must. No network (the digest resolver is replaced), no Docker. python3 scripts/test_ladder_writer.py """ import importlib.util import io import json import os import shutil import sys import tempfile import unittest from contextlib import redirect_stdout HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, HERE) import image_digest # noqa: E402 import ladder # noqa: E402 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) D = "sha256:" + "c" * 64 # 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), "memory": {"containers": {"navidrome": {"peak_pct": peak}}}} class WriterTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp(prefix="ladder-writer-") 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 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) def tearDown(self): image_digest.resolve = self._orig shutil.rmtree(self.tmp, ignore_errors=True) def run_writer(self, b, box_verdict="proven"): bp, xp = os.path.join(self.tmp, "b.json"), os.path.join(self.tmp, "x.json") json.dump(b, open(bp, "w")) json.dump({"verdict": box_verdict, "to": b["to"]}, open(xp, "w")) buf = io.StringIO() with redirect_stdout(buf): rc = ut.write_ladder([bp, "--box", xp, "--catalog", self.tmp, "--evidence", "ev/x/"]) return rc, buf.getvalue() def test_writes_what_the_gate_accepts(self): rc, out = self.run_writer(bench()) self.assertEqual(rc, 0, out) entries, _, errs = ladder.parse(open(self.fy).read()) self.assertEqual(errs, []) 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:" + 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) self.assertEqual(r.returncode, 0, r.stdout) def test_refuses_a_failed_bench(self): rc, out = self.run_writer(bench(verdict="failed")) self.assertEqual(rc, 1) self.assertNotIn("update_ladder:", open(self.fy).read()) def test_refuses_a_failed_box(self): rc, out = self.run_writer(bench(), box_verdict="inconclusive") self.assertEqual(rc, 1) 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.0.1")) self.assertEqual(rc, 1) self.assertIn("the template is at", out) def test_marks_follow_the_measurement(self): rc, out = self.run_writer(bench(peak=0.86, marks=["memory_tight", "files_may_change"])) self.assertEqual(rc, 0, out) 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/.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") smp = os.path.join(self.tmp, "templates", "navidrome", ladder.step_meta_file({"navidrome": "deluan/navidrome:" + NEXT})) self.assertTrue(os.path.isfile(smp), "the step's own .felhom.yml (R-664) was not written") self.assertNotIn("update_ladder:", open(smp).read()) 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) # --- --restep (2026-09-30, Part D): rewrite ONE superseded step's own definition from a re-proof --------------- def _two_steps(self): rc, out = self.run_writer(bench()); self.assertEqual(rc, 0, out) rc, out = self.run_writer(bench(frm=NEXT, to=AFTER)); self.assertEqual(rc, 0, out) return ladder.step_file({"navidrome": "deluan/navidrome:" + NEXT}) def run_restep(self, b, definition_images=None, extra=""): d = os.path.join(self.tmp, "def"); os.makedirs(d, exist_ok=True) imgs = definition_images or b["to"] open(os.path.join(d, "docker-compose.yml"), "w").write( "services:\n navidrome:\n image: %s\n deploy:\n resources:\n limits:\n memory: 999M\n%s" % (imgs["navidrome"], extra)) meta = open(os.path.join(self.tmp, "templates", "navidrome", ladder.step_meta_file(b["to"]))).read() \ if os.path.exists(os.path.join(self.tmp, "templates", "navidrome", ladder.step_meta_file(b["to"]))) \ else open(self.fy).read() open(os.path.join(d, ".felhom.yml"), "w").write(meta + "\n# RESTEP-META-MARKER\n") bp = os.path.join(self.tmp, "rb.json"); json.dump(b, open(bp, "w")) buf = io.StringIO() with redirect_stdout(buf): rc = ut.write_restep([bp, "--definition", d, "--catalog", self.tmp, "--evidence", "ev/restep/"]) return rc, buf.getvalue() def test_restep_rewrites_the_superseded_steps_files_and_nothing_else(self): key = self._two_steps() fy_before = open(self.fy).read() rc, out = self.run_restep(bench()) self.assertEqual(rc, 0, out) sp = os.path.join(self.tmp, "templates", "navidrome", key) self.assertIn("memory: 999M", open(sp).read()) self.assertIn("RE-PROVEN", open(sp).read()) self.assertIn("RESTEP-META-MARKER", open(sp[:-4] + ".felhom.yml").read()) self.assertEqual(open(self.fy).read(), fy_before, "the ladder entry must not be touched") 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) def test_restep_refuses_the_head_a_failed_bench_and_a_wrong_definition(self): key = self._two_steps() sp = os.path.join(self.tmp, "templates", "navidrome", key) before = open(sp).read() rc, out = self.run_restep(bench(frm=NEXT, to=AFTER)) self.assertEqual(rc, 1); self.assertIn("HEAD", out) rc, out = self.run_restep(bench(verdict="failed")) self.assertEqual(rc, 1) rc, out = self.run_restep(bench(), definition_images={"navidrome": "deluan/navidrome:" + AFTER}) self.assertEqual(rc, 1); self.assertIn("the definition names", out) b = bench(); b["memory"]["containers"]["navidrome"]["oom_kill"] = 3 rc, out = self.run_restep(b) self.assertEqual(rc, 1); self.assertIn("OOM", out) self.assertEqual(open(sp).read(), before, "a refused restep must not write") # --- a RE-TEST (decision 52): the same tag at a new digest ------------------------------------------------- def run_retest(self, new_digest, registry, box_evidence="ev/box/"): if "update_ladder:" not in open(self.fy).read(): rc, out = self.run_writer(bench()); self.assertEqual(rc, 0, out) # the head: LIVE -> NEXT at digest D ref = "deluan/navidrome:" + NEXT b = bench(frm=NEXT, to=NEXT) b["from"] = {"navidrome": ref + "@" + D} b["to"] = {"navidrome": ref + "@" + new_digest} image_digest.resolve = lambda r: (registry, None) bp, xp = os.path.join(self.tmp, "rb.json"), os.path.join(self.tmp, "rx.json") json.dump(b, open(bp, "w")); json.dump({"verdict": "proven", "to": {"navidrome": ref}}, open(xp, "w")) args = [bp, "--box", xp, "--catalog", self.tmp, "--evidence", "ev/bench/"] if box_evidence: args += ["--box-evidence", box_evidence] buf = io.StringIO() with redirect_stdout(buf): rc = ut.write_ladder(args) return rc, buf.getvalue() def test_retest_writes_a_same_tag_entry_the_gate_accepts(self): """COMPANION RED-PROOF: drop the `retest` digest_from block in write_ladder → the gate refuses 'needs digest_from'.""" E = "sha256:" + "e" * 64 rc, out = self.run_retest(E, E) self.assertEqual(rc, 0, out) entries, _, errs = ladder.parse(open(self.fy).read()) self.assertEqual(errs, []) e = entries[-1] self.assertEqual(e["from"], e["to"]) self.assertEqual(e["digest"], {"navidrome": E}) self.assertEqual(e["digest_from"], {"navidrome": D}) 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) def test_retest_refuses_without_the_box_and_on_a_moved_digest(self): E, F = "sha256:" + "e" * 64, "sha256:" + "f" * 64 rc, out = self.run_retest(E, E, box_evidence=None) self.assertEqual(rc, 1); self.assertIn("box venue", out) rc, out = self.run_retest(E, F) # the registry moved again after the bench tested E self.assertEqual(rc, 1); self.assertIn("re-run", out) if __name__ == "__main__": unittest.main(verbosity=2)