From a492294dd77ee4011eb3d01f27cc5da5b30e0717 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:51:46 -0600 Subject: [PATCH 1/4] 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 /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) --- .gitignore | 1 + scripts/backup-db.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100755 scripts/backup-db.sh 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 -- 2.45.2 From a09860dc61d5e263764d438f34b47a6fdbbe623e Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:51:49 -0600 Subject: [PATCH 2/4] feat(ops): alembic env hook invokes backup-db.sh before migrations Runs at env module load so the backup fires ahead of offline and online migration paths, as well as alembic current / revision. A failing backup does not stop the migration: this is defense in depth, not a hard prerequisite, and the common failure case is "DB does not exist yet" on a fresh checkout. Refs #5 Co-Authored-By: Claude Opus 4.7 (1M context) --- alembic/env.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/alembic/env.py b/alembic/env.py index 920a806..4522785 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,4 +1,6 @@ +import subprocess from logging.config import fileConfig +from pathlib import Path from sqlalchemy import engine_from_config, pool @@ -7,6 +9,28 @@ from alembic import context from quartermaster.config import DB_URL from quartermaster.models import Base + +def _backup_before_migrations() -> None: + script = Path(__file__).resolve().parents[1] / "scripts" / "backup-db.sh" + if not script.is_file(): + return + result = subprocess.run( + [str(script), "alembic"], + check=False, + capture_output=True, + text=True, + ) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0 and result.stderr: + print( + f"alembic: backup-db.sh returned {result.returncode}; " + f"continuing without a fresh backup.\n{result.stderr}", + ) + + +_backup_before_migrations() + config = context.config config.set_main_option("sqlalchemy.url", DB_URL) -- 2.45.2 From 9ee934629acc90a874736a3e0ac9032465e093ca Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:51:52 -0600 Subject: [PATCH 3/4] docs: document DB safety rule in CLAUDE.md and README CLAUDE.md states the durable rule: run scripts/backup-db.sh before any schema change, data migration, or destructive DB operation. The rule deliberately excludes routine app writes. README summarises backup location, override env var, and restore procedure. Refs #5 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 17 +++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..260a740 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# Quartermaster (repo instructions) + +## Database safety rule + +The SQLite database at `./quartermaster.db` (or whatever path +`QUARTERMASTER_DB_URL` points at) holds real user data. It is +`.gitignored`, so losing it means the data is gone. + +**Before any operation that interacts with the database at the schema +or destructive level**, run: + +```sh +./scripts/backup-db.sh +``` + +That includes: + +* `rm`, `mv`, truncate, or otherwise replace `quartermaster.db` +* Ad-hoc `sqlite3` sessions that might `DROP`, `DELETE`, or `UPDATE` +* Any script that does data migration or cleanup outside the running + application + +Alembic runs `scripts/backup-db.sh alembic` automatically on every +`alembic upgrade`, `alembic downgrade`, or `alembic revision --autogenerate` +via the hook in `alembic/env.py`, so you do not need to call it +manually for ordinary schema work. The hook is defense in depth, not +a substitute for thinking. + +Routine writes through the running web application are NOT covered by +this rule. Those are normal application behaviour, not "interactions" +in the sense meant here. + +## Where backups go + +Default: `/backups/quartermaster-YYYYMMDD-HHMMSS-{slug}.db`. +For the standard `./quartermaster.db` that resolves to `./backups/`. +Override with `QUARTERMASTER_BACKUP_DIR=/some/path`. + +The `backups/` directory is `.gitignored`. Retention is forever: +backups are small. Prune manually if you need to. + +## Restoring + +A backup is a complete SQLite file. To restore, stop the app, replace +`quartermaster.db` with the chosen backup (`cp backups/... quartermaster.db`), +then restart. diff --git a/README.md b/README.md index b7bdc5d..62d9582 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,23 @@ uv run pytest Tests run against an in-memory SQLite database; no migration step needed. +## Backups + +The SQLite data file is precious and gitignored. Before any schema +change or destructive operation, back it up: + +```sh +./scripts/backup-db.sh +``` + +Backups land next to the database in `./backups/` by default +(`QUARTERMASTER_BACKUP_DIR=/some/path` to override) as +`quartermaster-YYYYMMDD-HHMMSS-{slug}.db` using SQLite's online backup +API (safe even while the app is writing). Alembic invokes the script +automatically before every migration via `alembic/env.py`; retention +is forever for now. To restore, stop the app, copy the chosen backup +over `quartermaster.db`, and restart. + ## Project Layout ``` -- 2.45.2 From 9f1dd7a914dde9d7aea255f61a354825a8378869 Mon Sep 17 00:00:00 2001 From: archeious Date: Fri, 17 Apr 2026 11:51:55 -0600 Subject: [PATCH 4/4] test: cover backup-db.sh exit paths and slug sanitisation Asserts soft exit when the source DB is missing, a successful backup round-trips sqlite rows, a non-file sqlite URL is rejected, the reason slug defaults to "manual", and a messy reason is sanitised to a safe filename fragment. Refs #5 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_backup_script.py | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/test_backup_script.py diff --git a/tests/test_backup_script.py b/tests/test_backup_script.py new file mode 100644 index 0000000..bf0078c --- /dev/null +++ b/tests/test_backup_script.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import os +import shutil +import sqlite3 +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "backup-db.sh" + + +def _run(args: list[str], env_overrides: dict[str, str], cwd: Path): + env = os.environ.copy() + env.update(env_overrides) + return subprocess.run( + [str(SCRIPT), *args], + check=False, + capture_output=True, + text=True, + cwd=str(cwd), + env=env, + ) + + +def test_backup_exit_zero_when_db_missing(tmp_path): + fake_db = tmp_path / "ghost.db" + result = _run( + [], + {"QUARTERMASTER_DB_URL": f"sqlite:///{fake_db}"}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert "nothing to back up" in result.stdout + + +def test_backup_creates_file(tmp_path): + # seed a real sqlite DB + db_path = tmp_path / "qm.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE t (x INTEGER)") + conn.execute("INSERT INTO t VALUES (1), (2), (3)") + conn.commit() + conn.close() + + result = _run( + ["unit-test"], + {"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + backup_dir = tmp_path / "backups" + assert backup_dir.is_dir() + backups = list(backup_dir.glob("quartermaster-*-unit-test.db")) + assert len(backups) == 1 + # restored backup reads the same rows + restored = sqlite3.connect(str(backups[0])) + rows = list(restored.execute("SELECT x FROM t ORDER BY x")) + restored.close() + assert rows == [(1,), (2,), (3,)] + + +def test_backup_rejects_non_sqlite_url(tmp_path): + result = _run( + [], + {"QUARTERMASTER_DB_URL": "postgres://example"}, + tmp_path, + ) + assert result.returncode == 1 + assert "not a sqlite URL" in result.stderr + + +def test_backup_slug_defaults_to_manual(tmp_path): + db_path = tmp_path / "qm.db" + sqlite3.connect(str(db_path)).close() + result = _run( + [], + {"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + backups = list((tmp_path / "backups").glob("*manual*.db")) + assert len(backups) == 1 + + +def test_backup_slug_sanitizes_reason(tmp_path): + db_path = tmp_path / "qm.db" + sqlite3.connect(str(db_path)).close() + result = _run( + ["alembic upgrade!! head"], + {"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + backups = list((tmp_path / "backups").glob("*.db")) + assert len(backups) == 1 + assert "alembic-upgrade-head" in backups[0].name -- 2.45.2