#!/usr/bin/env python3 """fixprobe.py — fixes A and B live on a throwaway Vikunja this run installs (never a household's data). A (the undo copy only after a stable start): the app's unit edited to run `busybox` with `sleep 20; exit 1` (restart policy on, no healthcheck — it reads „running" for 20 s, then crashes and restarts) → „Visszaállítás": the page says not started yet, the undo copy stays, the operator line is logged once; the original unit back → a normal restore: the copy is there right after, gone after the 3-minute window. B (the `.fab` import downloads first): the app exported through the page's API, a COPY of the bundle under its own name with the image tag edited to one that does not exist → import: refused, the containers never restarted (StartedAt), the volumes untouched (their CreatedAt), the item still there. The test bundle and the export are removed after. The unit / bundle edits are the only hand-made changes (`pct exec`, files only). usage: SC=… SWEEP_EV=… TARGET=9202 DRIVE_ROOT=… python3 fixprobe.py """ import json, os, re, sys, time TOOLS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../catalog-sweep-2026-10-24/tools") sys.path.insert(0, TOOLS) import sweep as sw # noqa: E402 w, fxm = sw.w, sw.fxm APP, REPO = "vikunja", "vikunja/vikunja" NS = "/mnt/sys_drive/felhom-data" UNIT = f"{NS}/backups/primary/{APP}" ORIG = f"{NS}/backups/fix-a-test-original/{APP}" UNDO = f"{NS}/backups/restore-undo/{APP}" DRIVE = os.environ.get("DRIVE_ROOT", "/mnt/felhom-drives/scratch_hdd") def started(): return sw.g(f"for c in $(docker ps -a --filter label=com.docker.compose.project={APP} -q); do " "docker inspect -f '{{.Name}} {{.State.StartedAt}} {{.RestartCount}} {{.State.Status}}' $c; done").strip() def vols(): return sw.g(f"for v in $(docker volume ls -q --filter label=com.docker.compose.project={APP}); do " "docker volume inspect -f '{{.Name}} {{.CreatedAt}}' $v; done").strip() def sums(p): return sw.g(f"cd {p} 2>/dev/null && find . -type f | sort | xargs -r sha256sum") def main(): d = os.path.join(sw.EV, "fixprobe") os.makedirs(d, exist_ok=True) log = open(os.path.join(d, "walk.log"), "a") def say(*a): line = time.strftime("%H:%M:%SZ", time.gmtime()) + " " + " ".join(str(x) for x in a) print(line, flush=True) log.write(sw.scrub(line) + "\n") log.flush() w.say = say fx = fxm.FIXTURES[APP] sub = getattr(fx, "sub", APP) rec = {} t_start = int(time.time()) w.login() test_fab = "" try: rec["install"] = w.deploy(APP, sub) and w.wait_app(sub, "/", tries=90, delay=5) tok = fx.seed(w, sub, say) rec["seed"] = bool(tok) sw.backup_now(rec, say) sw.g(f"rm -rf {ORIG}; mkdir -p {NS}/backups/fix-a-test-original && cp -a {UNIT} {ORIG}") s0 = sums(UNIT) # --- A: an app that runs 20 s, crashes, restarts out = sw.g(f"cd {UNIT} && sed -i -E 's#{REPO}:[^\"[:space:]]*#busybox:1.36.1#g' compose/docker-compose.yml manifest.json data-stamps.json && " "sed -i -E 's#^( image: busybox:1.36.1)$#\\1\\n command: [\"sh\", \"-c\", \"sleep 20; exit 1\"]#' compose/docker-compose.yml && " "grep -n 'image:\\|command:' compose/docker-compose.yml") say(" [A] the unit now runs: " + out.replace("\n", " | ")) sw.restore_unit(APP, rec, say) rec["a_restore"] = rec.pop("restore") say(" [A] restore: " + rec["a_restore"].get("status", "")[:400]) rec["a_containers"] = started() rec["a_undo"] = sw.g(f"ls -1 {UNDO} 2>&1").strip() say(f" [A] containers: {rec['a_containers']} | kept undo copy: {rec['a_undo']}") time.sleep(60) rec["a_containers_60s_later"] = started() say(f" [A] 60 s later: {rec['a_containers_60s_later']}") # --- A: the original unit back, a normal restore, the copy released after the window sw.g(f"rm -rf {UNIT} && cp -a {ORIG} {UNIT}") rec["original_back"] = sums(UNIT) == s0 sw.g(f"rm -rf {UNDO}") # the copy the not-started run kept (mine), so the next count is clean sw.restore_unit(APP, rec, say) rec["a_normal_restore"] = rec.pop("restore") say(" [A] normal restore: " + rec["a_normal_restore"].get("status", "")[:300]) rec["a_undo_right_after"] = sw.g(f"ls -1 {UNDO} 2>&1").strip() say(f" [A] the copy right after the success: {rec['a_undo_right_after']}") for _ in range(30): time.sleep(10) left = sw.g(f"ls -1 {UNDO} 2>&1").strip() if "No such file" in left or not left: break rec["a_undo_after_window"] = sw.g(f"ls -1 {UNDO} 2>&1").strip() rec["a_item_after"] = bool(tok) and bool(fx.verify(w, sub, tok, say)) say(f" [A] after the window: {rec['a_undo_after_window'] or '(gone)'}; item {rec['a_item_after']}") # --- B: export, a copy with a missing tag, import c, r = w.ctl("POST", "/api/export/start", {"stack_name": APP, "dest_drive": DRIVE, "password": "", "stop_app": False}) say(f" [B] export start -> {c} {str(r)[:160]}") for _ in range(60): time.sleep(5) c, st = w.ctl("GET", "/api/export/status") stt = json.dumps(st, ensure_ascii=False) if '"running": false' in stt or '"state": "done"' in stt or '"done"' in stt: break c, b = w.ctl("GET", "/api/export/bundles") paths = re.findall(r'"path": "([^"]+vikunja[^"]*\.fab)"', json.dumps(b)) rec["b_bundle"] = paths[:1] if not paths: say(" [B] no bundle found: " + json.dumps(b)[:300]) return src = paths[0] test_fab = os.path.join(os.path.dirname(src), "vikunja-r1279-test.fab") out = sw.g(f"set -e; t=$(mktemp -d); cd $t; f={src}; if gzip -t $f 2>/dev/null; then tar xzf $f; z=z; else tar xf $f; z=; fi; " f"sed -i -E 's#{REPO}:[^\"[:space:]]*#{REPO}:0.0.0-felhom-r1279-missing#g' config/docker-compose.yml; " f"grep -n 'image:' config/docker-compose.yml; tar c${{z}}f {test_fab} .; cd /; rm -rf $t; ls -la {test_fab}") say(" [B] the test bundle: " + out.replace("\n", " | ")) before_c, before_v = started(), vols() c, r = w.ctl("POST", "/api/export/import", {"path": test_fab, "password": ""}) say(f" [B] import -> {c} {str(r)[:160]}") last = None for _ in range(60): time.sleep(5) c, st = w.ctl("GET", "/api/export/import/status") last = json.dumps(st, ensure_ascii=False) if "nem lehet letölteni" in last or '"error"' in last and '"running": false' in last or '"done"' in last: break rec["b_import_status"] = last[:900] if last else "" after_c, after_v = started(), vols() rec["b_containers_before"], rec["b_containers_after"] = before_c, after_c rec["b_volumes_before"], rec["b_volumes_after"] = before_v, after_v rec["b_untouched"] = bool(before_c) and before_c == after_c and before_v == after_v rec["b_item_still_there"] = bool(tok) and bool(fx.verify(w, sub, tok, say)) say(f" [B] import status: {rec['b_import_status'][:400]}") say(f" [B] containers and volumes untouched: {rec['b_untouched']}; item {rec['b_item_still_there']}") finally: rec["controller_lines"] = sw.scrub(sw.g( f"docker logs --since {t_start} felhom-controller 2>&1 | grep -E '{APP}' | grep -iE 'R-1270|R-1279|D48|stabl|not start|released|REFUSED|Import' | cut -c1-320")) sw.g(f"rm -f {test_fab} {os.path.dirname(test_fab)}/vikunja*.fab" if test_fab else "true") sw.remove(APP, keep=False, say=say) rec["after_remove"] = sw.leftovers(APP, sub) rec["cleanup"] = sw.g(f"rm -rf {ORIG} {UNDO}; rmdir {NS}/backups/fix-a-test-original 2>/dev/null; ls -d {ORIG} {UNDO} 2>&1").strip() json.dump(rec, open(os.path.join(d, "record.json"), "w"), ensure_ascii=False, indent=1) if __name__ == "__main__": main()