From 9d80e6d06e6353cd4c3153c916573b477a892716 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 22:31:28 +0000 Subject: [PATCH] Add scheduled backups of database and uploads The backend now writes a timestamped tar.gz of the SQLite snapshot and all per-order files to BACKUP_DIR on a cron schedule (default 03:00), keeps the newest BACKUP_KEEP archives, and catches up once on start if a slot was missed. Configurable via BACKUP_CRON / BACKUP_KEEP / BACKUP_DIR / TZ. Includes user-facing restore docs in docs/backups.md. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 +- README.md | 6 ++ docs/backups.md | 48 ++++++++++++ src/backend/app/main.py | 6 ++ src/backend/app/services/backup_service.py | 86 ++++++++++++++++++++++ src/backend/requirements.txt | 1 + src/backend/tests/test_backup.py | 49 ++++++++++++ src/compose.yml | 5 ++ 8 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docs/backups.md create mode 100644 src/backend/app/services/backup_service.py create mode 100644 src/backend/tests/test_backup.py diff --git a/.gitignore b/.gitignore index b3ed6d4..397fac2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ .venv/ .env .DS_Store -.data/ \ No newline at end of file +.data/ +.backups/ diff --git a/README.md b/README.md index a1c599e..8ac824a 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ services: image: git.sgruber.at/lunchtime/lunchtime-web:backend-latest volumes: - ./.data:/app/data + - ./.backups:/app/backups - ./config.yaml:/app/config.yaml:ro environment: - APP_ENV=production - LOG_LEVEL=info - DB_PATH=/app/data + - TZ=Europe/Vienna # backup schedule follows this timezone restart: unless-stopped frontend: @@ -47,6 +49,10 @@ docker compose up -d Open `http://localhost:8020`. +## Backups + +The backend writes a nightly archive of the database and all uploaded files to `/app/backups` (mount it, as above). Schedule, retention and restore steps: [docs/backups.md](docs/backups.md). + ## Development ### Quick Start with Docker Compose diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..89bf203 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,48 @@ +# Backups + +The backend backs itself up. By default, every night at 03:00 it writes one archive containing the SQLite database and all uploaded/per-order files (`configs/`, `descriptions/`, `templates/`, `payments/`, `images/`, `notifications/`) to `/app/backups`, and keeps the newest 14. + +Mount `/app/backups` to a host folder (the compose files use `./.backups`) and copy or mount that wherever you like. + +Archives are named `lunchtime-YYYYMMDD-HHMMSS.tar.gz` (local time of the container, see `TZ`). The database inside is a consistent snapshot taken while the app is running. + +## Configuration + +Environment variables of the `backend` service: + +| Variable | Default | Meaning | +|---------------|---------------|---------| +| `BACKUP_CRON` | `0 3 * * *` | Standard 5-field cron expression. Empty disables backups. An invalid value is logged as an error and disables backups. | +| `BACKUP_KEEP` | `14` | Number of archives to retain; older ones are deleted after each run. | +| `BACKUP_DIR` | `/app/backups`| Where archives are written. Keep it outside `/app/data`. | +| `TZ` | container default (UTC) | Timezone the cron expression is evaluated in, e.g. `Europe/Vienna`. | + +If the container was down at the scheduled time, it takes one backup on the next start instead of waiting for the next slot. Failures are logged (`backup failed`) and retried at the next slot; they never affect the app. + +## Restoring + +This replaces the current data. To keep it, copy `.data` aside first. + +```bash +# 1. Stop the backend so nothing writes while you restore +docker compose stop backend + +# 2. Empty the data folder (host path mounted at /app/data) and extract the archive into it +rm -rf .data/* +tar -xzf .backups/lunchtime-20261105-030000.tar.gz -C .data + +# 3. Start again +docker compose start backend +``` + +The archive's top level is the contents of the data folder (`burger_orders.db`, `configs/`, `images/`, ...), so extracting into the data folder restores everything. Do not leave old `burger_orders.db-wal` / `-shm` files from the previous data in place; the `rm -rf` in step 2 removes them. + +To restore into a fresh install, create the data folder, extract the archive into it, then start the stack. + +To restore only a single file (for example one order's image), extract just that path: + +```bash +tar -xzf .backups/lunchtime-20261105-030000.tar.gz -C .data images/ +``` + +Restoring only part of the database is not possible this way; the database is restored as a whole. diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 988b9ec..4577e53 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -36,6 +36,7 @@ from .services.account_service import ( ) from .services.common_service import clean_image_url, clean_user_email, clean_user_id, ensure_email_not_privileged, is_site_admin, now_iso from .services.stats_service import get_managed_stats, get_my_stats, get_site_stats +from .services.backup_service import start_backup_scheduler from .services.config_service import get_email_settings, load_config from .services.email_service import ( send_email_change_new_email_confirmation, @@ -235,6 +236,11 @@ def startup() -> None: init_db() +@app.on_event("startup") +async def start_backups() -> None: + start_backup_scheduler() + + def get_existing_user_id(user_id: str = Header(alias="X-User-Id")) -> str: clean_id = clean_user_id(user_id) with get_connection() as conn: diff --git a/src/backend/app/services/backup_service.py b/src/backend/app/services/backup_service.py new file mode 100644 index 0000000..1a4c982 --- /dev/null +++ b/src/backend/app/services/backup_service.py @@ -0,0 +1,86 @@ +import asyncio +import logging +import os +import sqlite3 +import tarfile +import tempfile +from datetime import datetime +from pathlib import Path + +from croniter import croniter + +from ..db import DB_PATH, data_dir + +log = logging.getLogger("uvicorn.error") # the only logger uvicorn shows at INFO by default + +# Schedule follows the container's local time (set TZ to change it). Empty BACKUP_CRON disables backups. +BACKUP_CRON = os.getenv("BACKUP_CRON", "0 3 * * *").strip() +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/app/backups")) +BACKUP_KEEP = int(os.getenv("BACKUP_KEEP", "14")) + +_task: asyncio.Task | None = None # keep a reference so the loop isn't garbage-collected + + +def list_backups(backup_dir: Path) -> list[Path]: + return sorted(backup_dir.glob("lunchtime-*.tar.gz")) # timestamped names sort chronologically + + +def run_backup(src_dir: Path, backup_dir: Path, keep: int) -> Path: + """Archive the DB (consistent online snapshot) and all other files in src_dir, then prune old archives.""" + backup_dir.mkdir(parents=True, exist_ok=True) + final = backup_dir / datetime.now().strftime("lunchtime-%Y%m%d-%H%M%S.tar.gz") + tmp = final.with_name(final.name + ".tmp") # rename at the end: never leave a half-written archive + db_name = (src_dir / DB_PATH.name).name + + with tempfile.TemporaryDirectory() as td: + snapshot = Path(td) / db_name + src, dst = sqlite3.connect(src_dir / db_name), sqlite3.connect(snapshot) + try: + src.backup(dst) + finally: + src.close() + dst.close() + with tarfile.open(tmp, "w:gz") as tar: + tar.add(snapshot, arcname=db_name) + for entry in src_dir.iterdir(): + # skip the live DB (+ -wal/-shm/-journal) and the backup dir itself if it sits inside src_dir + if entry.name.startswith(db_name) or entry.resolve() == backup_dir.resolve(): + continue + tar.add(entry, arcname=entry.name) + tmp.replace(final) + + # shortcut: a file deleted mid-archive (order deleted at the exact moment) fails this run; next run retries + for old in list_backups(backup_dir)[: -max(keep, 1)]: + old.unlink() + return final + + +def _run_safely() -> None: + try: + log.info("backup written: %s", run_backup(data_dir, BACKUP_DIR, BACKUP_KEEP)) + except Exception: + log.exception("backup failed") + + +async def _loop() -> None: + # Catch up once if the newest archive predates the last scheduled slot (e.g. host was off at 03:00). + last_slot = croniter(BACKUP_CRON, datetime.now()).get_prev(datetime) + backups = list_backups(BACKUP_DIR) + if not backups or datetime.fromtimestamp(backups[-1].stat().st_mtime) < last_slot: + await asyncio.to_thread(_run_safely) + while True: + now = datetime.now() + next_slot = croniter(BACKUP_CRON, now).get_next(datetime) + await asyncio.sleep((next_slot - now).total_seconds() + 1) # +1s: never wake before the slot and double-fire + await asyncio.to_thread(_run_safely) + + +def start_backup_scheduler() -> None: + global _task + if not BACKUP_CRON: + log.info("backups disabled (BACKUP_CRON is empty)") + elif not croniter.is_valid(BACKUP_CRON): + log.error("backups disabled: BACKUP_CRON %r is not a valid cron expression", BACKUP_CRON) + else: + log.info("backups scheduled: cron %r, keep %d, dir %s", BACKUP_CRON, BACKUP_KEEP, BACKUP_DIR) + _task = asyncio.create_task(_loop()) diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 6f35445..bb985d8 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -4,3 +4,4 @@ PyYAML==6.0.2 python-multipart==0.0.20 email-validator==2.2.0 watchdog[watchmedo]==4.0.2 +croniter==6.0.0 diff --git a/src/backend/tests/test_backup.py b/src/backend/tests/test_backup.py new file mode 100644 index 0000000..b496bad --- /dev/null +++ b/src/backend/tests/test_backup.py @@ -0,0 +1,49 @@ +"""Run from src/backend: python -m unittest discover tests""" +import os +import sqlite3 +import tarfile +import tempfile +import time +import unittest +from pathlib import Path + +os.environ["DB_PATH"] = tempfile.mkdtemp() # app.db creates its data dir on import + +from app.services.backup_service import list_backups, run_backup + + +class BackupTest(unittest.TestCase): + def test_archive_restores_db_and_files_and_prunes(self): + with tempfile.TemporaryDirectory() as td: + src, out = Path(td) / "data", Path(td) / "backups" + (src / "images").mkdir(parents=True) + (src / "images" / "a.png").write_bytes(b"png") + conn = sqlite3.connect(src / "burger_orders.db") + conn.execute("CREATE TABLE t (x)") + conn.execute("INSERT INTO t VALUES (42)") + conn.commit() + conn.close() + + for _ in range(3): + run_backup(src, out, keep=2) + time.sleep(1.1) # names have 1s resolution + self.assertEqual(len(list_backups(out)), 2) + self.assertEqual(list(out.glob("*.tmp")), []) + + restore = Path(td) / "restore" + with tarfile.open(list_backups(out)[-1]) as tar: + tar.extractall(restore, filter="data") + self.assertEqual(sqlite3.connect(restore / "burger_orders.db").execute("SELECT x FROM t").fetchone(), (42,)) + self.assertEqual((restore / "images" / "a.png").read_bytes(), b"png") + + def test_backup_dir_inside_data_dir_is_not_archived_into_itself(self): + with tempfile.TemporaryDirectory() as td: + src = Path(td) + sqlite3.connect(src / "burger_orders.db").close() + path = run_backup(src, src / "backups", keep=5) + with tarfile.open(path) as tar: + self.assertEqual(tar.getnames(), ["burger_orders.db"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/compose.yml b/src/compose.yml index 11ed7b3..6ba03b1 100644 --- a/src/compose.yml +++ b/src/compose.yml @@ -5,12 +5,17 @@ services: dockerfile: Containerfile volumes: - ./.data:/app/data + - ./.backups:/app/backups - ../config.yaml:/app/config.yaml:ro - ./backend/app:/app/app environment: - APP_ENV=production - LOG_LEVEL=info - DB_PATH=/app/data + # Nightly backup (see docs/backups.md); BACKUP_CRON="" disables, BACKUP_KEEP = archives to retain + - BACKUP_CRON=${BACKUP_CRON:-0 3 * * *} + - BACKUP_KEEP=${BACKUP_KEEP:-14} + - TZ=${TZ:-Europe/Vienna} # Comma-separated emails of admin users (shown with a badge in the UI) - ADMIN_EMAILS=${ADMIN_EMAILS:-} - SMTP_HOST=mailpit