Files
flowdeck/app/services/backup.py
T
bruno ffa1fa89ab
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 10m12s
fix: A25 + A21 (partiel) — plus d'exception muque, transaction protégée (v7.3.8)
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
  (19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
  api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
  sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
  `create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
  transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
  qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
  unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
  restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
2026-10-01 08:16:42 -04:00

112 lines
3.2 KiB
Python

"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
from __future__ import annotations
import logging
import shutil
import time
from datetime import datetime
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
def _backup_dir() -> Path:
d = Path(settings.backup_dir)
d.mkdir(parents=True, exist_ok=True)
return d
def _db_path() -> Path:
return settings.db_path
def backup_db(now: datetime | None = None) -> str | None:
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
Returns the backup filename, or None when backup is disabled or the
database file does not exist.
"""
if not settings.backup_enabled:
return None
db_path = _db_path()
if str(db_path) == ":memory:" or not Path(db_path).is_file():
return None
now = now or datetime.now()
# Checkpoint the WAL so the backup is consistent.
try:
import sqlite3
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
logger.exception("backup_db")
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
dest = dest_dir / filename
shutil.copy2(db_path, dest)
# Prune old backups, keeping ``backup_keep`` most recent files.
prune_old_backups()
logger.info("Backup created: %s", dest)
return filename
def list_backups() -> list[dict]:
"""List existing backup files (name, size bytes, mtime)."""
files = []
for p in _backup_dir().glob("flowdeck-*.db"):
stat = p.stat()
files.append({
"filename": p.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
files.sort(key=lambda f: f["filename"], reverse=True)
return files
def prune_old_backups(keep: int | None = None) -> int:
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
keep = keep if keep is not None else settings.backup_keep
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
removed = 0
for p in files[keep:]:
try:
p.unlink()
removed += 1
except OSError:
logger.warning("Could not prune backup %s", p)
return removed
def last_backup_age_hours() -> float | None:
"""Hours since the most recent backup (None if none exists)."""
files = list(_backup_dir().glob("flowdeck-*.db"))
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
age = time.time() - newest.stat().st_mtime
return age / 3600
def backup_due() -> bool:
"""True when a backup should run now (interval elapsed since last one)."""
age = last_backup_age_hours()
if age is None:
return True
return age >= settings.backup_interval_hours
async def backup_scheduler():
"""Background loop: run a backup once per interval (default daily)."""
while True:
try:
if backup_due():
backup_db()
except Exception as exc: # never let the loop die
logger.warning("backup_scheduler error: %s", exc)
await __import__("asyncio").sleep(3600) # re-check hourly