rehearsal 2026-10-20: pre-night household snapshot (byte compare vs manifest), B7 plan written before
gates / gates (push) Successful in 12m56s

This commit is contained in:
2026-10-20 23:09:29 +02:00
parent 938afb7416
commit 0d4bfe7ca6
4 changed files with 200 additions and 0 deletions
@@ -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.
@@ -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
@@ -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.
@@ -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'<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>'
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</"))
def share():
# the password travels in smbclient's PASSWD environment variable, never on its command line
r = subprocess.run(["smbclient", f"//felhom.{D}/Filmek", "-U", "felhom", "-m", "SMB3", "-c", "recurse; ls"],
env=dict(os.environ, PASSWD=os.environ["SMB"]), capture_output=True, text=True)
lines = [l for l in r.stdout.splitlines() if l.startswith(" ") and " D " not in l and "blocks of size" not in l]
print(f"share Filmek: rc {r.returncode}; {len(lines)} files")
for l in lines[:20]:
print(" ", l.strip()[:110])
def vault():
r = subprocess.run(["bash", "-c", "export NODE_TLS_REJECT_UNAUTHORIZED=0 BITWARDENCLI_APPDATA_DIR=/root/work/bwsnap; rm -rf $BITWARDENCLI_APPDATA_DIR; "
"mkdir -p -m 700 $BITWARDENCLI_APPDATA_DIR; bw config server https://vault.tester4.test >/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()