Backup script

This commit is contained in:
2026-06-19 17:42:28 +01:00
parent b093e1a90c
commit 0026ac2274
4 changed files with 154 additions and 8 deletions
+1
View File
@@ -20,3 +20,4 @@ node_modules/
.cache/ .cache/
.pytest_cache/ .pytest_cache/
drugs.db drugs.db
data/backups/
+32 -3
View File
@@ -77,9 +77,38 @@ docker-compose up --build
## Database ## Database
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can: SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
- Backup by copying the file
- Share with team members ### 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 ## Configuration
+18 -5
View File
@@ -10,8 +10,8 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- DATABASE_URL=sqlite:///./data/drugs.db - DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto} - MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883} - MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_USERNAME=${MQTT_USERNAME:-}
@@ -23,6 +23,20 @@ services:
- LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false} - 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: mosquitto:
image: eclipse-mosquitto:latest image: eclipse-mosquitto:latest
volumes: volumes:
@@ -31,12 +45,11 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log - mosquitto_logs:/mosquitto/log
environment: environment:
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
frontend: frontend:
image: nginx:alpine image: nginx:alpine
container_name: drugsdev
volumes: volumes:
- ./frontend:/usr/share/nginx/html:ro - ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+103
View File
@@ -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()