#!/usr/bin/env python3 """undo_proof.py β€” Part 3: the big step's UNDO, proven on scratch 9202 through the product's own guarded Update. Jellyfin 10.11.11 -> 12.4 converts its database with no way back (the bench's abort put the old image back on the converted data: β€žthe app started but the seeded data was gone"). So the box's undo must bring back the DATA, not only the old image. The proof: 1. the drill's jellyfin at FROM (a temporary drill commit), install, seed the first user, read it back (C1); 2. a temporary drill commit moves the image to 12.4 AND makes its checks fail (compose healthcheck `false`, the .felhom.yml probe on a path that does not exist) β€” so 12.4 starts, converts the database, and the Update fails in its check; 3. press the guarded Update: the final phase must be `undone`; while it verifies, 12.4's own log is saved (the conversion is seen, not assumed); 4. after the undo: the running image is 10.11.11 and the seeded user signs in (a wrong password refused); 5. the two temporary drill commits are reverted, the app removed. usage: SC= TARGET=9202 EV= python3 undo_proof.py """ import json, os, re, subprocess, sys, threading, time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w import upgrade_fixtures_box28 as f28 APP, SUB = "jellyfin", "jellyfin" FROM, TO = "jellyfin/jellyfin:10.11.11", "jellyfin/jellyfin:12.4" D = w.DRILL EV = os.environ["EV"] os.makedirs(EV, exist_ok=True) rec = {"app": APP, "from": FROM, "to": TO, "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} def git(*a): return subprocess.run(["git", "-C", D] + list(a), capture_output=True, text=True, timeout=120) def drill_commit(msg, edit): git("pull", "-q", "--rebase", "origin", "main") comp, fy = f"{D}/templates/{APP}/docker-compose.yml", f"{D}/templates/{APP}/.felhom.yml" c, y = open(comp).read(), open(fy).read() c, y = edit(c, y) open(comp, "w").write(c) open(fy, "w").write(y) git("add", f"templates/{APP}") git("commit", "-q", "-m", msg) r = git("push", "-q", "origin", "main") h = git("rev-parse", "--short=12", "HEAD").stdout.strip() w.say(f" drill commit {h}: {msg} (push rc={r.returncode})") w.refresh_box_mirror() return h def finish(verdict, why): rec["verdict"], rec["why"] = verdict, why rec["log"] = w.LOG json.dump(rec, open(os.path.join(EV, "undo-proof-jellyfin.json"), "w"), indent=2) w.say(f"RESULT {verdict} β€” {why}") w.login() head0 = git("rev-parse", "HEAD").stdout.strip() rec["drill_head_before"] = head0 fx = f28.FIXTURES28[APP] commits = [] try: def to_from(c, y): return c.replace(f"image: {TO}", f"image: {FROM}"), y commits.append(drill_commit("UNDO-PROOF setup: jellyfin at FROM (temporary, reverted at the end)", to_from)) if w.stack(APP).get("deployed"): w.remove(APP) w.sync_rescan(APP, FROM, tries=40, delay=10) if not w.deploy(APP, SUB): finish("inconclusive", "the install at FROM did not complete"); raise SystemExit(1) w.wait_app(SUB, "/System/Info/Public", want=("200",), tries=60) tok = fx.seed(w, SUB, w.say) if not tok or not fx.verify(w, SUB, tok, w.say): finish("inconclusive", "C1: the seed did not read back before the update"); raise SystemExit(1) def failing_to(c, y): c = c.replace(f"image: {FROM}", f"image: {TO}") c = c.replace('test: ["CMD", "curl", "-f", "http://127.0.0.1:8096/health"]', 'test: ["CMD", "false"]') y = y.replace('path: "/health"', 'path: "/undo-proof-no-such-path"', 1) return c, y commits.append(drill_commit("UNDO-PROOF: jellyfin 12.4 whose checks fail (temporary, reverted at the end)", failing_to)) w.sync_rescan(APP, TO, tries=40, delay=10) seen = {"lines": []} stop = threading.Event() def watch(): while not stop.is_set(): out = w.guest("docker inspect -f '{{.Config.Image}}' jellyfin 2>/dev/null; " "docker logs --tail 400 jellyfin 2>&1 | grep -iE 'migrat|Jellyfin version|database' | tail -40") if "12.4" in out.split("\n")[0]: seen["lines"] = out.strip().split("\n") time.sleep(5) th = threading.Thread(target=watch, daemon=True) th.start() rec["update"] = u = w.press_update(APP, poll=1.0, cap_s=1800) stop.set() th.join(timeout=30) rec["to_log_while_verifying"] = seen["lines"][-40:] time.sleep(15) rec["observables_after"] = obs = w.observables(APP) running = w.guest("docker inspect -f '{{.Config.Image}}' jellyfin 2>/dev/null").strip() rec["running_image_after"] = running if u.get("final_phase") != "undone": finish("failed", f"the update ended {u.get('final_phase')!r}, not 'undone'"); raise SystemExit(1) if running != FROM: finish("failed", f"after the undo the running image is {running!r}, not {FROM}"); raise SystemExit(1) if not any(re.search(r"migrat", l, re.I) for l in seen["lines"]): w.say(" note: no migration line was captured from 12.4 while it verified") rec["seed_after_undo"] = ok = fx.verify(w, SUB, tok, w.say) if not ok: finish("failed", "the undo put the old image back but the seeded user does not sign in"); raise SystemExit(1) finish("proven", "12.4 started on the data and failed its check; the box undid it: 10.11.11 runs again and the " "seeded user signs in (a wrong password refused)") finally: if w.stack(APP).get("deployed"): w.remove(APP) for h in reversed(commits): git("pull", "-q", "--rebase", "origin", "main") r = git("revert", "--no-edit", h) w.say(f" drill revert {h}: rc={r.returncode}") if commits: git("push", "-q", "origin", "main") w.refresh_box_mirror() rec["drill_head_after"] = git("rev-parse", "HEAD").stdout.strip() rec["drill_tree_equal_to_before"] = git("diff", "--quiet", head0, "HEAD", "--", f"templates/{APP}").returncode == 0 w.say(f" drill templates/{APP} equal to before: {rec['drill_tree_equal_to_before']}") json.dump({k: v for k, v in rec.items() if k != "log"} | {"log": w.LOG}, open(os.path.join(EV, "undo-proof-jellyfin.json"), "w"), indent=2)