feat(ops): add backup-db.sh for safe sqlite snapshots
Uses sqlite3.Connection.backup for an online, WAL-safe copy. Resolves the DB path from QUARTERMASTER_DB_URL or ./quartermaster.db, places the snapshot in <db-dir>/backups (override with QUARTERMASTER_BACKUP_DIR), timestamps the filename, and tags it with an optional reason slug. Absent DB file is a soft exit so the script is safe to call from hooks. Refs #5 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d205d2853
commit
a492294dd7
2 changed files with 76 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,6 +65,7 @@ quartermaster.db
|
||||||
quartermaster.db-journal
|
quartermaster.db-journal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
backups/
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
|
|
|
||||||
75
scripts/backup-db.sh
Executable file
75
scripts/backup-db.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Copy the quartermaster SQLite database into backups/ before any schema
|
||||||
|
# change, data migration, or destructive operation. Safe to run while the
|
||||||
|
# application is writing to the DB: uses Python's sqlite3.Connection.backup
|
||||||
|
# (the online backup API), not a plain file copy.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/backup-db.sh [reason]
|
||||||
|
#
|
||||||
|
# `reason` becomes part of the backup filename. Defaults to "manual".
|
||||||
|
#
|
||||||
|
# Resolves the database path in this order:
|
||||||
|
# 1. QUARTERMASTER_DB_URL if it starts with sqlite:/// (uses the path part)
|
||||||
|
# 2. ./quartermaster.db relative to the repo root
|
||||||
|
#
|
||||||
|
# Exit status:
|
||||||
|
# 0 backup written, or source DB does not exist (nothing to do)
|
||||||
|
# 1 backup failed, or a non-file sqlite URL was supplied
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
reason="${1:-manual}"
|
||||||
|
|
||||||
|
exec python3 - "$reason" <<'PY'
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
reason = sys.argv[1]
|
||||||
|
|
||||||
|
db_path = Path("quartermaster.db")
|
||||||
|
url = os.environ.get("QUARTERMASTER_DB_URL")
|
||||||
|
if url:
|
||||||
|
if url.startswith("sqlite:///"):
|
||||||
|
db_path = Path(url[len("sqlite:///"):])
|
||||||
|
elif url.startswith("sqlite://"):
|
||||||
|
print(f"backup-db.sh: non-file sqlite URL not supported: {url}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"backup-db.sh: QUARTERMASTER_DB_URL is not a sqlite URL: {url}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not db_path.is_file():
|
||||||
|
print(f"backup-db.sh: no database at {db_path}, nothing to back up.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
slug = re.sub(r"[^A-Za-z0-9]+", "-", reason).strip("-") or "manual"
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_root = os.environ.get("QUARTERMASTER_BACKUP_DIR")
|
||||||
|
if backup_root:
|
||||||
|
dest_dir = Path(backup_root)
|
||||||
|
else:
|
||||||
|
dest_dir = db_path.resolve().parent / "backups"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / f"quartermaster-{timestamp}-{slug}.db"
|
||||||
|
|
||||||
|
src = sqlite3.connect(str(db_path))
|
||||||
|
try:
|
||||||
|
dst = sqlite3.connect(str(dest))
|
||||||
|
try:
|
||||||
|
src.backup(dst)
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
|
||||||
|
print(f"backup-db.sh: {db_path} -> {dest} ({dest.stat().st_size} bytes)")
|
||||||
|
PY
|
||||||
Loading…
Reference in a new issue