diff --git a/.gitignore b/.gitignore index 96a8788..2bd166e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ quartermaster.db quartermaster.db-journal *.sqlite *.sqlite3 +backups/ # Flask stuff: instance/ diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..68dd404 --- /dev/null +++ b/scripts/backup-db.sh @@ -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