Files
felhom.eu/documentation/audits/release-2026-10-26/tools/fixprobe.py
T

153 lines
8.0 KiB
Python

#!/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()