From 0d4bfe7ca6aa688e7138c9d2c818e49628c4a857 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 20 Oct 2026 23:09:29 +0200 Subject: [PATCH] rehearsal 2026-10-20: pre-night household snapshot (byte compare vs manifest), B7 plan written before --- .../B5-night/00-expected.txt | 5 + .../B5-night/01-before-night-snapshot.txt | 20 +++ .../rehearsal-2026-10-20/B7-died/00-plan.md | 33 ++++ .../rehearsal-2026-10-20/tools/snapshot.py | 142 ++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 documentation/audits/rehearsal-2026-10-20/B5-night/01-before-night-snapshot.txt create mode 100644 documentation/audits/rehearsal-2026-10-20/B7-died/00-plan.md create mode 100644 documentation/audits/rehearsal-2026-10-20/tools/snapshot.py diff --git a/documentation/audits/rehearsal-2026-10-20/B5-night/00-expected.txt b/documentation/audits/rehearsal-2026-10-20/B5-night/00-expected.txt index 0ff7b6c1..5a481c96 100644 --- a/documentation/audits/rehearsal-2026-10-20/B5-night/00-expected.txt +++ b/documentation/audits/rehearsal-2026-10-20/B5-night/00-expected.txt @@ -19,3 +19,8 @@ Expected, each with a POSITIVE observable to look for in the morning: 5. The box does not freeze (R-1192): memwatch keeps answering; health 200 every minute. 6. Mail: the household gets nothing on a good night; the operator gets nothing about Tester4 on a good night. Anything missing from 1–6 is a finding, written with its evidence before any fix or retry. + +Baseline taken 20:59–21:09 UTC (before the night, read-only, through the apps' own front doors): `01-before-night-snapshot.txt` +(tool `tools/snapshot.py`). The morning snapshot must equal it line for line. The four known Nextcloud gaps are from +the day (the 1.5 GB upload that never finished, a symlink WebDAV cannot hold, the NFC/NFD pair Nextcloud folds into one +name) and the four 0-byte `feltoltes_*.jpg` are R-1196. diff --git a/documentation/audits/rehearsal-2026-10-20/B5-night/01-before-night-snapshot.txt b/documentation/audits/rehearsal-2026-10-20/B5-night/01-before-night-snapshot.txt new file mode 100644 index 00000000..3829887e --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/B5-night/01-before-night-snapshot.txt @@ -0,0 +1,20 @@ +paperless: 47 documents; originals byte-identical to the manifest 47, not 0 +immich: 277 assets; originals byte-identical to the manifest 277, not 0 +nextcloud: manifest 3034 files; byte-identical 3030; missing 3; different 1; extra 4 + MISSING nextcloud/Csalad/nagy_fajl_1_5GB.bin + MISSING nextcloud/Csalad/Kulonleges_nevek/Ödön születésnapja – fénykép lista.txt + MISSING nextcloud/Csalad/Kulonleges_nevek/link_a_nagy_fajlra + DIFFERS nextcloud/Csalad/Kulonleges_nevek/Ödön születésnapja – fénykép lista.txt + EXTRA nextcloud/Csalad/feltoltes_4.jpg + EXTRA nextcloud/Csalad/feltoltes_3.jpg + EXTRA nextcloud/Csalad/feltoltes_1.jpg + EXTRA nextcloud/Csalad/feltoltes_2.jpg +mealie: recipes ['Csirkepörkölt', 'Gulyásleves'] +bookstack: pages ['Családi wiki 0. oldal', 'Családi wiki 1. oldal', 'Családi wiki 2. oldal', 'Családi wiki 3. oldal', 'Családi wiki 4. oldal'] | images 5 +radicale: PROPFIND 207 | events 2 +share Filmek: rc 0; 4 files + Ödön születésnapja – fénykép lista.txt N 56 Tue Oct 20 17:21:31 2026 + Ödön születésnapja – fénykép lista.txt N 56 Tue Oct 20 17:21:31 2026 + Csaladi_film_2 (2026).mkv A 949649288 Tue Oct 20 15:26:40 2026 + Csaladi_film_1 (2026).mkv A 949649129 Tue Oct 20 15:26:29 2026 +vaultwarden: items, attachments: 50 2 diff --git a/documentation/audits/rehearsal-2026-10-20/B7-died/00-plan.md b/documentation/audits/rehearsal-2026-10-20/B7-died/00-plan.md new file mode 100644 index 00000000..21aa15d8 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/B7-died/00-plan.md @@ -0,0 +1,33 @@ +# B7 — „the box died" — plan and expectations, written 2026-10-20 ~20:50 UTC, before any of it ran + +Runs after the post-night scenarios (#7, #8, #10). Machine: VM 9320 `rehearsal1020-box1` on demo-hp (Tier 0), created by +this run. Hub host: **Tester4-816e21**. + +## Steps + +1. **Before the box dies — the record to compare against.** Through each app's own front door: Paperless count + failed + list, Immich statistics, Nextcloud `Csalad` size and sha256 of the A5 sample files, Vaultwarden item count, + BookStack pages/images, Mealie recipes, Actual transactions, Radicale events, the share „Filmek" listing. The + off-site snapshot list (`/api/backup/snapshots`). Saved here as `01-before/`. Copied off the box = it is already on + DooPlex (all reads run from here). +2. **The box dies:** `qm stop 9320` (no shutdown), then `qm destroy 9320 --purge` — its own disks only, on + nvme-scratch. Time noted. +3. **Reinstall WITHOUT the operator's press (the forgotten press):** a new VM from the same ISO, the guide as written. + Expected: the bind or the installer refuses because the old host still exists; the text is recorded word for word + (HU and EN if the page has both). Expected wording per installer 1.35.0: tells the household to have the old box + deleted first. Whatever the box shows, nothing is forced. +4. **Ask the operator** (phone notification + STATUS): „delete host Tester4-816e21 on the Hosts page". Then wait until + the end of the run; record the minutes. +5. **If the press comes:** reinstall/bind/claim again; the dashboard should ask for the recovery code or offer the + earlier off-site backups. R-1176 says it will NOT (a new key is minted and the old history set aside on night one) — + this is its live test. Restore everything from off-site through the household's pages; compare per app with step 1 + and per file with the A5 manifest (sha256). +6. **If the press does not come:** STATUS gets the next steps; the reinstall part of B7 is marked NOT RUN with the + reason. + +## Expected, each with a positive observable + +- The hub marks Tester4-816e21 stale ≈ 45 min after the kill; the operator gets host_stale / node_stale mails. +- The forgotten press is refused with a sentence that says what to do (not a bare „409"). +- After the press: the household can bring back every app and the share from off-site, with the same counts as + step 1, and the A5 sample files byte-identical. diff --git a/documentation/audits/rehearsal-2026-10-20/tools/snapshot.py b/documentation/audits/rehearsal-2026-10-20/tools/snapshot.py new file mode 100644 index 00000000..9885f9e2 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/snapshot.py @@ -0,0 +1,142 @@ +#!/usr/bin/python3 +"""snapshot.py — the household's data, read through each app's OWN front door and compared with the A5 manifest +(rehearsal 2026-10-20: B5 morning, B7 before/after). Runs in the browser LXC (/root/work, /root/data/MANIFEST.tsv). + +Env (secrets arrive through envrun.py, never on a command line): ODON (Ödön's password: Nextcloud, Immich, Vaultwarden), +IRISZ (Paperless), KATA (Mealie), BSTOK (BookStack token), RADI (Radicale), SMB (the share password). +Prints one line per check; never prints a secret. Exit 0 always — the lines are the result. +""" +import hashlib, json, os, subprocess, sys, tempfile, urllib.parse +sys.path.insert(0, "/root/work") +from apps import http, basic + +D = "tester4.test" +MAN = {} +for line in open("/root/data/MANIFEST.tsv", encoding="utf-8"): + p = line.rstrip("\n").split("\t") + if p[0] != "path": + MAN[p[0]] = (int(p[1]) if p[1].isdigit() else -1, p[2]) +by_name = {} +for path, (size, sha) in MAN.items(): + by_name.setdefault((path.split("/")[0], os.path.basename(path)), []).append(sha) + + +def sha(b): + return hashlib.sha256(b).hexdigest() + + +def paperless(): + H = {"Authorization": basic("irisz", os.environ["IRISZ"])} + st, out, _ = http("GET", f"https://paperless.{D}/api/documents/?page_size=200", None, H) + if st != 200: + return print("paperless: ANSWER", st, out[:100]) + docs = json.loads(out)["results"] + ok = bad = 0 + for d in docs: + s2, o2, _ = http("GET", f"https://paperless.{D}/api/documents/{d['id']}/download/?original=true", None, H) + want = by_name.get(("paperless", d["original_file_name"]), []) + if s2 == 200 and sha(o2 if isinstance(o2, bytes) else o2.encode()) in want: + ok += 1 + else: + bad += 1 + print(f"paperless: {len(docs)} documents; originals byte-identical to the manifest {ok}, not {bad}") + + +def immich(): + st, out, _ = http("POST", f"https://photos.{D}/api/auth/login", {"email": "tester4+odon@felhom.eu", "password": os.environ["ODON"]}) + if st not in (200, 201): + return print("immich: login", st) + H = {"Authorization": "Bearer " + json.loads(out)["accessToken"]} + st, out, _ = http("POST", f"https://photos.{D}/api/search/metadata", {"size": 1000}, H) + items = json.loads(out)["assets"]["items"] if st == 200 else [] + ok = bad = 0 + for a in items: + s2, o2, _ = http("GET", f"https://photos.{D}/api/assets/{a['id']}/original", None, H) + if s2 == 200 and sha(o2) in by_name.get(("immich", a["originalFileName"]), []): + ok += 1 + else: + bad += 1 + print(f"immich: {len(items)} assets; originals byte-identical to the manifest {ok}, not {bad}") + + +def nextcloud(): + obs = subprocess.run(["rclone", "obscure", "-"], input=os.environ["ODON"], capture_output=True, text=True).stdout.strip() + env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://cloud.{D}/remote.php/dav/files/odon", + RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER="odon", RCLONE_CONFIG_NC_PASS=obs) + with tempfile.TemporaryDirectory(dir="/root/work") as t: + r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", "2", "--bwlimit", "30M", "nc:/Csalad", t + "/Csalad"], + env=env, capture_output=True, text=True) + if r.returncode: + print("nextcloud: rclone copy rc", r.returncode, r.stderr[-200:].replace("\n", " | ")) + got = {} + for dp, _, fs in os.walk(t): + for f in fs: + rel = "nextcloud/" + os.path.relpath(os.path.join(dp, f), t) + got[rel] = sha(open(os.path.join(dp, f), "rb").read()) + want = {p: v for p, v in MAN.items() if p.startswith("nextcloud/Csalad/")} + same = sum(1 for p, (s, h) in want.items() if got.get(p) == h) + missing = [p for p in want if p not in got] + differ = [p for p in want if p in got and got[p] != want[p][1]] + extra = [p for p in got if p not in want] + print(f"nextcloud: manifest {len(want)} files; byte-identical {same}; missing {len(missing)}; different {len(differ)}; extra {len(extra)}") + for p in (missing + differ)[:12]: + print(" ", "MISSING" if p in missing else "DIFFERS", p) + for p in extra[:8]: + print(" EXTRA", p) + + +def mealie(): + st, out, _ = http("POST", f"https://recipes.{D}/api/auth/token", + urllib.parse.urlencode({"username": "changeme@example.com", "password": os.environ["KATA"]}).encode(), + {"Content-Type": "application/x-www-form-urlencoded"}) + if st != 200: + return print("mealie: login", st) + H = {"Authorization": "Bearer " + json.loads(out)["access_token"]} + st, out, _ = http("GET", f"https://recipes.{D}/api/recipes?perPage=100", None, H) + print("mealie: recipes", sorted(x["name"] for x in json.loads(out)["items"]) if st == 200 else st) + + +def bookstack(): + tid, tsec = os.environ["BSTOK"].split(":", 1) + H = {"Authorization": f"Token {tid}:{tsec}"} + st, out, _ = http("GET", f"https://wiki.{D}/api/pages?count=100", None, H) + pages = sorted(x["name"] for x in json.loads(out).get("data", [])) if st == 200 else st + st, out, _ = http("GET", f"https://wiki.{D}/api/image-gallery?count=100", None, H) + print("bookstack: pages", pages, "| images", len(json.loads(out).get("data", [])) if st == 200 else st) + + +def radicale(): + body = b'' + st, out, _ = http("PROPFIND", f"https://calendar.{D}/csalad/csalad/", body, + {"Authorization": basic("csalad", os.environ["RADI"]), "Depth": "1", "Content-Type": "application/xml"}) + txt = out.decode() if isinstance(out, bytes) else out + print("radicale: PROPFIND", st, "| events", txt.count(".ics/dev/null; " + "S=$(PW=$ODON bw login tester4+odon@felhom.eu --passwordenv PW --raw 2>/dev/null); " + "[ -n \"$S\" ] || { echo LOGIN-FAILED; exit 0; }; " + "bw list items --session \"$S\" 2>/dev/null | python3 -c 'import json,sys; i=json.load(sys.stdin); " + "print(len(i), sum(len(x.get(\"attachments\") or []) for x in i))'; bw logout >/dev/null 2>&1; rm -rf $BITWARDENCLI_APPDATA_DIR"], + capture_output=True, text=True, env=dict(os.environ, PATH="/usr/local/bin:/usr/bin:/bin")) + print("vaultwarden: items, attachments:", r.stdout.strip()[-60:]) + + +for name in (sys.argv[1:] or ["paperless", "immich", "nextcloud", "mealie", "bookstack", "radicale", "share", "vault"]): + try: + globals()[name]() + except Exception as e: # noqa: BLE001 — one app's failure is a result, not the end of the snapshot + print(f"{name}: CHECK FAILED {type(e).__name__}: {str(e)[:160]}") + sys.stdout.flush()