Files
OpenFUT/scripts/fifa17-promotion-backup.py
T
funman300 f0c6dcf238 tooling: verified backup, state snapshot, retargetable apply validator
Promotion prep for the contract-apply cutover, which unlike the quick-sell
promotion moves BOTH binaries and applies a schema migration.

fifa17-promotion-backup.py uses SQLite's online backup API, not cp. Production
runs WAL with a routinely uncheckpointed WAL (515 KB at capture time); copying
the main file alone is not atomic against a live writer and carries no
guarantee the WAL holds no newer committed state. Emits a checksummed backup, a
metadata record and a RESTORE-*.sh that removes the stale -wal/-shm BEFORE
restoring -- omit that and SQLite replays the old journal over the file you just
put back, resurrecting the state you were abandoning.

fifa17-promotion-snapshot.py is read-only (mode=ro) and counts EVERY table
rather than a hand-picked list, so a delta cannot hide in a table nobody thought
to name. It also fingerprints the ownership rows, catching a row silently
rewritten when counts alone would match.

fifa17-contract-apply-validate.py gains --host/--db so one tool serves staging,
the migration rehearsal and the production acceptance run. Defaults stay
staging: there is deliberately no production default, so a bare invocation
cannot touch production.
2026-08-22 18:40:12 +00:00

159 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Transactionally-consistent backup of a live OpenFUT Core SQLite DB.
WHY NOT `cp`. Production runs WAL mode with an uncheckpointed WAL that routinely
holds hundreds of KB of committed pages. Copying only `prod-core.db` captures the
main file WITHOUT those pages and silently loses committed transactions; copying
the three files non-atomically can capture a torn set. This uses SQLite's online
backup API, which walks a read transaction and emits ONE standalone, already-
merged database file — no sidecar needed, no writer paused, safe against a live
production process.
RESTORE HAZARD, read this before restoring. The destination of a restore MUST
have its `-wal` and `-shm` removed first. SQLite treats an existing `-wal` as
newer-than-the-database journal content and will replay it over the file you
just put back, resurrecting exactly the state you were trying to abandon. The
emitted `RESTORE.sh` does this in the right order.
Usage:
fifa17-promotion-backup.py <source-db> <backup-dir> [--label NAME]
"""
import argparse
import datetime
import hashlib
import json
import os
import sqlite3
import stat
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def describe(db_path, read_only=True):
uri = f"file:{db_path}?mode=ro" if read_only else db_path
con = sqlite3.connect(uri, uri=True)
one = lambda s: con.execute(s).fetchone()[0]
info = {
"journal_mode": one("PRAGMA journal_mode"),
"page_size": one("PRAGMA page_size"),
"page_count": one("PRAGMA page_count"),
"migration_max": one("SELECT MAX(version) FROM _sqlx_migrations"),
"migration_count": one("SELECT COUNT(*) FROM _sqlx_migrations"),
"integrity_check": one("PRAGMA integrity_check"),
"foreign_key_check": len(con.execute("PRAGMA foreign_key_check").fetchall()),
"owned_cards": one("SELECT COUNT(*) FROM owned_cards"),
"coins": one("SELECT COALESCE(SUM(coins), 0) FROM clubs"),
}
con.close()
return info
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source")
ap.add_argument("backup_dir")
ap.add_argument("--label", default="core")
args = ap.parse_args()
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
os.makedirs(args.backup_dir, exist_ok=True)
dest = os.path.join(args.backup_dir, f"{args.label}-{stamp}.db")
src_info = describe(args.source)
sidecars = {
os.path.basename(args.source) + suf:
(os.path.getsize(args.source + suf) if os.path.exists(args.source + suf) else None)
for suf in ("", "-wal", "-shm")
}
print(f"source : {args.source}")
print(f" journal : {src_info['journal_mode']} migration_max={src_info['migration_max']}")
print(f" sidecars : {sidecars}")
print(f" integrity : {src_info['integrity_check']} fk_violations={src_info['foreign_key_check']}")
# Online backup API. Source opened READ-ONLY: production is never written to,
# and in WAL mode this does not block the live writer.
src = sqlite3.connect(f"file:{args.source}?mode=ro", uri=True)
dst = sqlite3.connect(dest)
with dst:
src.backup(dst)
dst.close()
src.close()
os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP) # read-only: a backup is not scratch space
dst_info = describe(dest)
digest = sha256(dest)
# The backup is only a backup if it independently verifies. A mismatch here
# means DO NOT PROCEED — it does not mean "retry and hope".
checks = {
"integrity_ok": dst_info["integrity_check"] == "ok",
"no_fk_violations": dst_info["foreign_key_check"] == 0,
"migration_matches": dst_info["migration_max"] == src_info["migration_max"],
"owned_matches": dst_info["owned_cards"] == src_info["owned_cards"],
"coins_match": dst_info["coins"] == src_info["coins"],
}
meta = {
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
"source": os.path.abspath(args.source),
"source_sidecar_sizes": sidecars,
"source_info": src_info,
"backup_path": os.path.abspath(dest),
"backup_sha256": digest,
"backup_size": os.path.getsize(dest),
"backup_info": dst_info,
"verification": checks,
"method": "sqlite3 online backup API (Connection.backup), source opened mode=ro",
}
meta_path = dest + ".json"
open(meta_path, "w").write(json.dumps(meta, indent=2, sort_keys=True) + "\n")
restore = os.path.join(args.backup_dir, f"RESTORE-{args.label}-{stamp}.sh")
open(restore, "w").write(f"""#!/bin/sh
# Canonical restore for {os.path.abspath(args.source)}
# Generated {meta['taken_at']} from backup {os.path.basename(dest)}
#
# STOP EVERY WRITER FIRST. Restoring under a live Core corrupts both.
set -eu
TARGET='{os.path.abspath(args.source)}'
BACKUP='{os.path.abspath(dest)}'
test "$(sha256sum "$BACKUP" | cut -d' ' -f1)" = '{digest}' \\
|| {{ echo 'FATAL: backup checksum mismatch, refusing to restore'; exit 1; }}
# The stale -wal/-shm MUST go, or SQLite replays them over the restored file.
rm -f "$TARGET-wal" "$TARGET-shm"
cp "$BACKUP" "$TARGET"
chmod u+w "$TARGET"
sqlite3 "$TARGET" 'PRAGMA integrity_check;' 'PRAGMA foreign_key_check;' \\
'SELECT MAX(version) FROM _sqlx_migrations;'
echo 'restore complete -- now start the PREVIOUS Core and host binaries'
""")
os.chmod(restore, 0o755)
print(f"\nbackup : {dest}")
print(f" sha256 : {digest}")
print(f" size : {meta['backup_size']:,}")
print(f" integrity : {dst_info['integrity_check']} fk_violations={dst_info['foreign_key_check']}")
print(f" migration : {dst_info['migration_max']} owned={dst_info['owned_cards']} coins={dst_info['coins']:,}")
print(f"metadata : {meta_path}")
print(f"restore : {restore}")
print("\nverification:")
for k, v in checks.items():
print(f" [{'OK ' if v else 'FAIL'}] {k}")
ok = all(checks.values())
print("\nRESULT:", "BACKUP VERIFIED" if ok else "BACKUP FAILED VERIFICATION -- DO NOT PROCEED")
raise SystemExit(0 if ok else 1)
if __name__ == "__main__":
main()