"""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: pass 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