rehearsal 2026-10-20: pre-night household snapshot (byte compare vs manifest), B7 plan written before
gates / gates (push) Successful in 12m56s
gates / gates (push) Successful in 12m56s
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user