81 lines
2.4 KiB
Python
81 lines
2.4 KiB
Python
#!/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 <bot.db> <backup-dir> [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 <bot.db> <backup-dir> [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())
|