"""r638slice0.py — R-638 slice 0, MEASUREMENT ONLY, on scratch 9202 (drill catalog). `09` §3 decision 154. 1 the drill's template is put at the OLD definition (the commit before the R-462 move), ladder emptied; 2 install fresh through the product; seed through the app's own door; read back (C1); the live table list; 3 a DRILL commit moves it to the NEW definition with the one proven ladder entry; sync; the product's guarded Update (its backing-up phase makes the Tier-2 copy at the OLD version); the table list after the migration; 4 the named unit restore of that pre-update copy through the household's button (POST /backup/restore); 5 positive observable: the controller's `Imported DB dump` line + the seed read back; control from a different channel: the live table list against the table list READ FROM THE COPY'S OWN DUMP FILE on the Tier-2 drive — every table the migration added must be GONE; 6 removed through the product (KEEP=1 keeps it). Evidence: ../B/.txt + ../B/-tables.json.""" import json, os, re, subprocess, sys, time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w import upgrade_fixtures_box as fixtures APP = sys.argv[1] CFG = { "docmost": {"sub": "docmost", "old": "6d8cd87^", "new": "6d8cd87", "db": "docmost-postgres", "tables": "psql -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -Atc \"select tablename from pg_tables where schemaname='public' order by 1\""}, "romm": {"sub": "romm", "extra": {"HDD_PATH": "/mnt/felhom-drives/scratch_hdd"}, "old": "15f9ebf^", "new": "15f9ebf", "db": "romm-db", "tables": "MYSQL_PWD=\"$MYSQL_PASSWORD\" mariadb -u\"$MYSQL_USER\" \"$MYSQL_DATABASE\" -Nse 'show tables'"}, }[APP] LIVE = "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu" D = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill" HERE = os.path.dirname(os.path.abspath(__file__)) EV = os.path.join(HERE, "..", "B") log = open(os.path.join(EV, f"{APP}.txt"), "a", buffering=1) REC = {"app": APP} def say(*a): w.say(*a); log.write(" ".join(map(str, a)) + "\n") def git(*a, cwd=D): return subprocess.run(["git", "-C", cwd, *a], check=True, capture_output=True, text=True).stdout def live_tables(): out = w.guest(f"docker exec -i {CFG['db']} sh -s 2>&1 <<'SQLX'\n{CFG['tables']}\nSQLX\n") return sorted(t for t in out.split() if re.fullmatch(r"[A-Za-z0-9_]+", t)) def set_template(rev, ladder_entries, msg): git("pull", "-q", "--rebase", "origin", "main") comp = git("show", f"{rev}:templates/{APP}/docker-compose.yml", cwd=LIVE) open(f"{D}/templates/{APP}/docker-compose.yml", "w").write(comp) sys.path.insert(0, f"{LIVE}/scripts") import ladder fy = open(f"{LIVE}/templates/{APP}/.felhom.yml").read() entries, _, _ = ladder.parse(fy) head = fy.split("\nupdate_ladder:")[0].rstrip("\n") + "\n" if ladder_entries: keep = [e for e in entries if e in ladder_entries] for e in keep: head = ladder.append_entry(head, e) open(f"{D}/templates/{APP}/.felhom.yml", "w").write(head) subprocess.run(["rm", "-rf", f"{D}/templates/{APP}/steps"], check=True) subprocess.run(["git", "-C", D, "add", "-A", f"templates/{APP}"], check=True) git("commit", "-q", "--allow-empty", "-m", msg) git("push", "-q", "origin", "main") say("drill:", git("log", "--oneline", "-1").strip(), "| images:", re.findall(r"image:\s*(\S+)", comp)) return entries fx = fixtures.FIXTURES[APP] w.login() REC["controller"] = w.guest("docker inspect -f '{{.Config.Image}}' felhom-controller").strip() say("9202 controller:", REC["controller"]) if w.stack(APP).get("deployed"): say(f"{APP} already installed on 9202 — removing it first (scratch box)") w.remove(APP) sys.path.insert(0, f"{LIVE}/scripts") import ladder all_entries = ladder.parse(open(f"{LIVE}/templates/{APP}/.felhom.yml").read())[0] set_template(CFG["old"], [], f"DRILL {APP}: the OLD definition ({CFG['old']}) for R-638 slice 0") old_imgs = re.findall(r"image:\s*(\S+)", git("show", f"{CFG['old']}:templates/{APP}/docker-compose.yml", cwd=LIVE)) w.sync_rescan(APP, old_imgs[0]) # wait until the box's catalog carries the OLD definition (run 1 installed the live one) if not w.deploy(APP, CFG["sub"], CFG.get("extra")): sys.exit(say("RESULT the install did not complete") or 1) tok = fx.seed(w, CFG["sub"], say) if tok is None or not fx.verify(w, CFG["sub"], tok, say): sys.exit(say("RESULT C1 failed — the seed did not read back before") or 1) REC["pinned_old"] = (w.stack(APP).get("app_config") or {}).get("pinned_images") if old_imgs[0] not in (REC["pinned_old"] or {}).values(): sys.exit(say(f"RESULT the box installed {REC['pinned_old']}, not the OLD definition — stopping") or 1) REC["tables_old_live"] = live_tables() say(f"[2] old version installed, pinned={REC['pinned_old']}, live tables {len(REC['tables_old_live'])}") new_comp = git("show", f"{CFG['new']}:templates/{APP}/docker-compose.yml", cwd=LIVE) new_imgs = dict(re.findall(r"^ ([a-z0-9-]+):\s*\n(?:.*\n)*?\s+image:\s*(\S+)", new_comp, re.M)) step = all_entries[:1] # docmost 0.95.0 -> 0.96.0 (pg16), romm 5.0.0 -> 5.3.0 (mariadb 11.4) say(f"[3] the ladder entry used: {[ (e['from'], e['to']) for e in step ]}") set_template(CFG["new"], step, f"DRILL {APP}: the NEW definition ({CFG['new']}) + its one ladder entry, R-638 slice 0") w.sync_rescan(APP, next(iter(step[0]["to"].values())) if step else None) since = w.guest("date -u +%Y-%m-%dT%H:%M:%SZ").strip() res = w.press_update(APP, poll=1, cap_s=1800) REC["update_final_phase"] = res.get("final_phase") REC["pinned_new"] = (w.stack(APP).get("app_config") or {}).get("pinned_images") time.sleep(10) REC["tables_new_live"] = live_tables() added = sorted(set(REC["tables_new_live"]) - set(REC["tables_old_live"])) REC["tables_added_by_migration"] = added say(f"[3] update -> {res.get('final_phase')}, pinned={REC['pinned_new']}, live tables {len(REC['tables_new_live'])}, " f"added by the migration: {len(added)} {added}") REC["seed_after_update"] = fx.verify(w, CFG["sub"], tok, say) if res.get("final_phase") != "done" or not added: say("RESULT the update did not migrate (no done, or no new table) — slice 0 cannot measure; stopping before restore") json.dump(REC, open(os.path.join(EV, f"{APP}-tables.json"), "w"), indent=2) sys.exit(1) snaps = w.snapshots(APP) say(f"[4] restorable copies offered: {json.dumps(snaps)[:600]}") pick = snaps[0] if snaps else None sid = pick and (pick.get("id") or pick.get("snapshot_id") or pick.get("short_id")) since_r = w.guest("date -u +%Y-%m-%dT%H:%M:%SZ").strip() r = w.restore(APP, sid) REC["restore"] = {k: r.get(k) for k in ("snapshot_id", "http", "seconds", "state_after", "hold_after")} time.sleep(15) lines = w.guest(f"docker logs --since {since_r} felhom-controller 2>&1 | grep -v DEBUG | grep -i -E 'restor|Imported DB dump|replay|volume' | cut -c1-300") log.write(lines + "\n") REC["imported_db_dump_line"] = [l for l in lines.splitlines() if "Imported DB dump" in l] REC["tables_after_restore"] = live_tables() REC["seed_after_restore"] = fx.verify(w, CFG["sub"], tok, say) # control from a different channel: the table list in the copy's own dump file on the Tier-2 drive REC["unit_files"] = w.guest(f"find /mnt/felhom-drives/scratch_hdd/backups -path '*{APP}*' -type f 2>/dev/null | head -40").split() # the RESTORED copy's dump — never a `pre-restore-*` safety dump the restore itself wrote (run 3 of romm read one) dumps = "\n".join(f for f in REC["unit_files"] if re.search(r"(\.sql(\.gz|\.zst)?$|dump)", f) and "pre-restore" not in f) REC["dump_files_seen"] = dumps.split() copy_tables = [] if dumps.split(): f0 = dumps.split()[0] cat = "zcat" if f0.endswith(".gz") else ("zstdcat" if f0.endswith(".zst") else "cat") # tables AND views: `SHOW TABLES` / pg_tables list what the live side lists, so the copy side must name the same kinds # (romm has three views; run 3 counted only CREATE TABLE and read 24 of 27). pg_tables lists tables only. kinds = "TABLE|VIEW" if APP == "romm" else "TABLE" out = w.guest(f"{cat} '{f0}' | grep -o -E 'CREATE (ALGORITHM=[A-Z]+ )?(DEFINER=[^ ]+ )?(SQL SECURITY [A-Z]+ )?({kinds}) (IF NOT EXISTS )?[`\"]?([a-z]+\\.)?[`\"]?[A-Za-z0-9_]+' | sed -E 's/.*[ .`\"]//' | sort -u") copy_tables = sorted(set(t for t in out.split() if re.fullmatch(r"[A-Za-z0-9_]+", t))) REC["tables_in_copy_dump"] = copy_tables REC["dump_used_for_control"] = dumps.split()[:1] left = sorted(set(REC["tables_after_restore"]) & set(added)) REC["migration_tables_left_after_restore"] = left REC["live_equals_copy"] = REC["tables_after_restore"] == copy_tables say(f"[5] Imported DB dump lines: {REC['imported_db_dump_line']}") say(f"[5] seed read back after restore: {REC['seed_after_restore']}") say(f"[5] live tables after restore {len(REC['tables_after_restore'])}; the copy's own dump lists {len(copy_tables)}; " f"equal={REC['live_equals_copy']}; migration tables still there: {left}") REC["pinned_after_restore"] = (w.stack(APP).get("app_config") or {}).get("pinned_images") REC["state_after_restore"] = w.stack(APP).get("state") say(f"[5] pinned after restore {REC['pinned_after_restore']}, state {REC['state_after_restore']}") ok = bool(REC["imported_db_dump_line"]) and REC["seed_after_restore"] and not left and REC["live_equals_copy"] REC["verdict"] = "SAFE" if ok else "NOT SAFE / NOT SHOWN" say(f"RESULT {APP}: {REC['verdict']}") json.dump(REC, open(os.path.join(EV, f"{APP}-tables.json"), "w"), indent=2) if not os.environ.get("KEEP"): say(f"remove -> {w.remove(APP)}")