Compare commits
5 commits
b2d16120d2
...
2d7ce333ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7ce333ea | ||
|
|
9da7205f77 | ||
|
|
647bf257f4 | ||
|
|
c6d4d65fe6 | ||
|
|
b63daa958b |
6 changed files with 0 additions and 260 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,7 +65,6 @@ quartermaster.db
|
||||||
quartermaster.db-journal
|
quartermaster.db-journal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
backups/
|
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
|
|
|
||||||
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -1,46 +0,0 @@
|
||||||
# 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 <reason>
|
|
||||||
```
|
|
||||||
|
|
||||||
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: `<dir-of-db-file>/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.
|
|
||||||
17
README.md
17
README.md
|
|
@ -50,23 +50,6 @@ uv run pytest
|
||||||
|
|
||||||
Tests run against an in-memory SQLite database; no migration step needed.
|
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 <reason>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import subprocess
|
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
|
@ -9,28 +7,6 @@ from alembic import context
|
||||||
from quartermaster.config import DB_URL
|
from quartermaster.config import DB_URL
|
||||||
from quartermaster.models import Base
|
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 = context.config
|
||||||
config.set_main_option("sqlalchemy.url", DB_URL)
|
config.set_main_option("sqlalchemy.url", DB_URL)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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
|
|
||||||
Loading…
Reference in a new issue