Backup script
This commit is contained in:
@@ -20,3 +20,4 @@ node_modules/
|
|||||||
.cache/
|
.cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
drugs.db
|
drugs.db
|
||||||
|
data/backups/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user