#!/usr/bin/env python3 """Consistent, rotated bot.db backups (SPEC-012 OPS-13). Run from cron on each host. Uses the sqlite3 online-backup API so the snapshot is consistent even while the bot writes (WAL-safe), gzips it, and keeps the newest N. Stdlib only. Usage: python3 backup_db.py [keep] """ import gzip import os import shutil import sqlite3 import sys import tempfile import time from pathlib import Path DEFAULT_KEEP = 14 BACKUP_GLOB = "bot-*.db.gz" def snapshot(src: Path, dest_gz: Path) -> None: """Write a consistent gzipped snapshot of src to dest_gz (OPS-13).""" fd, tmp_path = tempfile.mkstemp(suffix=".db", dir=str(dest_gz.parent)) os.close(fd) tmp = Path(tmp_path) try: source = sqlite3.connect(str(src)) try: target = sqlite3.connect(str(tmp)) try: source.backup(target) # atomic, WAL-safe online backup finally: target.close() finally: source.close() with open(tmp, "rb") as raw, gzip.open(str(dest_gz), "wb") as gz: shutil.copyfileobj(raw, gz) os.chmod(dest_gz, 0o600) # conversation data finally: tmp.unlink(missing_ok=True) def victims(existing: list, keep: int) -> list: """Given backup paths (any order), return the ones to delete, oldest first (OPS-14).""" ordered = sorted(existing) # timestamped names sort chronologically return ordered[: max(0, len(ordered) - keep)] def rotate(backup_dir: Path, keep: int) -> int: removed = 0 for path in victims(list(backup_dir.glob(BACKUP_GLOB)), keep): Path(path).unlink(missing_ok=True) removed += 1 return removed def main() -> int: if len(sys.argv) < 3: print("usage: backup_db.py [keep]", file=sys.stderr) return 2 src = Path(sys.argv[1]).expanduser() backup_dir = Path(sys.argv[2]).expanduser() keep = int(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_KEEP if not src.exists(): print(f"backup: source {src} missing", file=sys.stderr) return 1 backup_dir.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime()) dest = backup_dir / f"bot-{stamp}.db.gz" snapshot(src, dest) removed = rotate(backup_dir, keep) print(f"backup: wrote {dest.name} ({dest.stat().st_size} bytes), rotated {removed} old") return 0 if __name__ == "__main__": sys.exit(main())