Files
felhom.eu/documentation/audits/security-app-updates-2026-10-27/part3/tools/undo_proof.py
T

130 lines
6.2 KiB
Python

#!/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=<dir with .ctlpw> TARGET=9202 EV=<dir> 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)