From 0026ac2274cfda6119505aa6be467c0e53618a54 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Fri, 19 Jun 2026 17:42:28 +0100 Subject: [PATCH] Backup script --- .gitignore | 1 + README.md | 35 +++++++++++-- docker-compose.yml | 23 +++++++-- scripts/backup_sqlite.py | 103 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 scripts/backup_sqlite.py diff --git a/.gitignore b/.gitignore index 5c07867..236c5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ node_modules/ .cache/ .pytest_cache/ drugs.db +data/backups/ diff --git a/README.md b/README.md index 456d0dc..b456afe 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,38 @@ docker-compose up --build ## Database -SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can: -- Backup by copying the file -- Share with team members +SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts. + +### Backups + +The Docker Compose stack includes a `backup` sidecar that creates a SQLite-safe compressed backup every hour and keeps backups for 7 days. + +Backups are stored in: + +```bash +./data/backups/ +``` + +The latest backup is also copied to: + +```bash +./data/backups/latest.db.gz +``` + +To restore a backup: + +```bash +docker compose stop backend backup +gunzip -c data/backups/drugs-YYYY-MM-DDTHH-MM-SSZ.db.gz > data/drugs.db +docker compose up -d backend backup +``` + +Backup interval and retention can be changed with: + +```bash +BACKUP_INTERVAL_SECONDS=3600 +BACKUP_RETENTION_DAYS=7 +``` ## Configuration diff --git a/docker-compose.yml b/docker-compose.yml index 9294fce..6fe629d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: - ./data:/app/data environment: - DATABASE_URL=sqlite:///./data/drugs.db - - PUID=1001 - - PGID=1001 + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} - MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto} - MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883} - MQTT_USERNAME=${MQTT_USERNAME:-} @@ -23,6 +23,20 @@ services: - LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_TEST=${LABEL_TEST:-false} + backup: + image: python:3.11-slim + user: "${PUID:-1000}:${PGID:-1000}" + volumes: + - ./data:/data + - ./scripts/backup_sqlite.py:/usr/local/bin/backup_sqlite.py:ro + environment: + - SQLITE_DB_PATH=/data/drugs.db + - BACKUP_DIR=/data/backups + - BACKUP_INTERVAL_SECONDS=${BACKUP_INTERVAL_SECONDS:-3600} + - BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-7} + command: ["python", "/usr/local/bin/backup_sqlite.py"] + restart: unless-stopped + mosquitto: image: eclipse-mosquitto:latest volumes: @@ -31,12 +45,11 @@ services: - mosquitto_data:/mosquitto/data - mosquitto_logs:/mosquitto/log environment: - - PUID=1001 - - PGID=1001 + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} frontend: image: nginx:alpine - container_name: drugsdev volumes: - ./frontend:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/scripts/backup_sqlite.py b/scripts/backup_sqlite.py new file mode 100644 index 0000000..ff6cc94 --- /dev/null +++ b/scripts/backup_sqlite.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import gzip +import os +import shutil +import sqlite3 +import time +from datetime import datetime, timezone +from pathlib import Path + + +DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/drugs.db")) +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups")) +INTERVAL_SECONDS = int(os.getenv("BACKUP_INTERVAL_SECONDS", "3600")) +RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "7")) +LATEST_BACKUP = BACKUP_DIR / "latest.db.gz" + + +def utc_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") + + +def log(message: str) -> None: + print(f"[{utc_stamp()}] {message}", flush=True) + + +def run_integrity_check(db_path: Path) -> None: + with sqlite3.connect(str(db_path)) as conn: + result = conn.execute("PRAGMA integrity_check").fetchone() + + if not result or result[0] != "ok": + detail = result[0] if result else "no result" + raise RuntimeError(f"SQLite integrity check failed: {detail}") + + +def create_backup() -> None: + if not DB_PATH.exists(): + log(f"database not found at {DB_PATH}; skipping backup") + return + + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + stamp = utc_stamp() + temp_db = BACKUP_DIR / f".{stamp}.db.tmp" + temp_gz = BACKUP_DIR / f".{stamp}.db.gz.tmp" + final_gz = BACKUP_DIR / f"drugs-{stamp}.db.gz" + + for path in (temp_db, temp_gz): + if path.exists(): + path.unlink() + + source_uri = f"file:{DB_PATH}?mode=ro" + with sqlite3.connect(source_uri, uri=True) as source: + with sqlite3.connect(str(temp_db)) as target: + source.backup(target) + + run_integrity_check(temp_db) + + with temp_db.open("rb") as raw, gzip.open(temp_gz, "wb", compresslevel=6) as compressed: + shutil.copyfileobj(raw, compressed) + + temp_gz.replace(final_gz) + shutil.copy2(final_gz, LATEST_BACKUP) + temp_db.unlink(missing_ok=True) + + size_kb = final_gz.stat().st_size / 1024 + log(f"created {final_gz.name} ({size_kb:.1f} KiB)") + + +def prune_old_backups() -> None: + if RETENTION_DAYS <= 0 or not BACKUP_DIR.exists(): + return + + cutoff = time.time() - (RETENTION_DAYS * 24 * 60 * 60) + deleted = 0 + + for backup in BACKUP_DIR.glob("drugs-*.db.gz"): + if backup.stat().st_mtime < cutoff: + backup.unlink() + deleted += 1 + + if deleted: + log(f"deleted {deleted} backup(s) older than {RETENTION_DAYS} day(s)") + + +def main() -> None: + log( + "starting SQLite backup loop: " + f"db={DB_PATH}, dir={BACKUP_DIR}, interval={INTERVAL_SECONDS}s, " + f"retention={RETENTION_DAYS}d" + ) + + while True: + try: + create_backup() + prune_old_backups() + except Exception as exc: + log(f"backup failed: {exc}") + + time.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + main()