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.
This commit is contained in:
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user