104 lines
2.8 KiB
Python
104 lines
2.8 KiB
Python
#!/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()
|