"""Scheduled local backup service. Creates ZIP snapshots of the full Bambuddy data (database + data directories) on a configurable schedule with retention management. """ import asyncio import logging from datetime import datetime, timedelta, timezone from pathlib import Path from sqlalchemy import select from backend.app.core.config import settings as app_settings from backend.app.core.database import async_session from backend.app.models.settings import Settings from backend.app.services.backup_path import classify_backup_dir_error, probe_backup_dir # The TZ-env resolution used to live here. It moved to utils/local_time when the # smart-plug energy history (#2539) needed the same local day boundary. Re-exported # under the old private name so existing importers keep working. from backend.app.utils.local_time import local_zone as _local_zone logger = logging.getLogger(__name__) SCHEDULE_INTERVALS = { "hourly": 3600, "daily": 86400, "weekly": 604800, } def _default_backup_dir() -> Path: return app_settings.base_dir / "backups" class LocalBackupService: """Manages scheduled local backup snapshots with retention.""" def __init__(self): self._scheduler_task: asyncio.Task | None = None self._check_interval = 60 self._running: bool = False self._last_backup_at: str | None = None self._last_status: str | None = None self._last_message: str | None = None self._next_run: datetime | None = None async def start_scheduler(self): """Start the background scheduler loop.""" if self._scheduler_task is not None: return logger.info("Starting local backup scheduler") # Seed next_run from settings so the first check has a target await self._seed_next_run() self._scheduler_task = asyncio.create_task(self._scheduler_loop()) def stop_scheduler(self): """Stop the scheduler.""" if self._scheduler_task: self._scheduler_task.cancel() self._scheduler_task = None logger.info("Stopped local backup scheduler") async def _scheduler_loop(self): """Main scheduler loop — checks for due backups every minute.""" while True: try: await asyncio.sleep(self._check_interval) await self._check_scheduled_backup() except asyncio.CancelledError: break except Exception as e: logger.error("Error in local backup scheduler: %s", e) await asyncio.sleep(60) async def _seed_next_run(self): """Load settings and calculate initial next_run.""" try: settings = await self._load_settings() if settings.get("enabled"): self._next_run = self._calculate_next_run( settings.get("schedule", "daily"), settings.get("time", "03:00"), ) except Exception as e: logger.debug("Could not seed local backup next_run: %s", e) async def _load_settings(self) -> dict: """Read local backup settings from the DB.""" async with async_session() as db: keys = [ "local_backup_enabled", "local_backup_schedule", "local_backup_time", "local_backup_retention", "local_backup_path", ] result = await db.execute(select(Settings).where(Settings.key.in_(keys))) rows = {r.key: r.value for r in result.scalars().all()} return { "enabled": rows.get("local_backup_enabled", "false").lower() == "true", "schedule": rows.get("local_backup_schedule", "daily"), "time": rows.get("local_backup_time", "03:00"), "retention": int(rows.get("local_backup_retention", "5")), "path": rows.get("local_backup_path", ""), } async def _check_scheduled_backup(self): """Check if a scheduled backup is due and run it.""" settings = await self._load_settings() if not settings["enabled"]: self._next_run = None return now = datetime.now(timezone.utc) # If no next_run set, schedule one if self._next_run is None: self._next_run = self._calculate_next_run(settings["schedule"], settings["time"]) return if self._next_run <= now: logger.info("Running scheduled local backup") await self.run_backup(settings) self._next_run = self._calculate_next_run(settings["schedule"], settings["time"]) def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime: """Calculate the next scheduled run time. For hourly: next full hour (timezone-agnostic). For daily/weekly: next occurrence of the configured HH:MM, interpreted in the container's local timezone (TZ env var, UTC fallback). Returns a UTC-aware datetime for storage / comparison against ``now``. """ now_utc = datetime.now(timezone.utc) if schedule_type == "hourly": # Next full hour next_run = now_utc.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) return next_run # Parse HH:MM time try: parts = time_str.strip().split(":") hour = int(parts[0]) minute = int(parts[1]) if len(parts) > 1 else 0 except (ValueError, IndexError): hour, minute = 3, 0 local_tz = _local_zone() now_local = now_utc.astimezone(local_tz) # Next occurrence of HH:MM local time, today or tomorrow. # ``fold=0`` resolves the ambiguous wall-clock window at DST fall-back # to the earlier instance (consistent with cron's behaviour). On the # spring-forward gap the synthesized local time will normalise to the # next valid instant when converted to UTC. next_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0) if next_local <= now_local: next_local += timedelta(days=1) if schedule_type == "weekly": next_local += timedelta(weeks=1) return next_local.astimezone(timezone.utc) def _resolve_backup_dir(self, path_setting: str) -> Path: """Resolve the backup output directory from settings.""" if path_setting.strip(): return Path(path_setting.strip()) return _default_backup_dir() def check_path(self, path_setting: str) -> dict: """Probe the configured output directory with a real write. Called when the path is saved and when the backup card is opened, so a directory the service cannot write to is caught there and then instead of at 03:00 for a week (#2544). """ return probe_backup_dir(self._resolve_backup_dir(path_setting)) async def run_backup(self, settings: dict | None = None) -> dict: """Run a backup now. Returns {success, message, filename}.""" if self._running: return {"success": False, "message": "Backup already in progress"} self._running = True try: if settings is None: settings = await self._load_settings() backup_dir = self._resolve_backup_dir(settings["path"]) try: backup_dir.mkdir(parents=True, exist_ok=True) from backend.app.api.routes.settings import create_backup_zip zip_path, filename = await create_backup_zip(output_path=backup_dir) except OSError as e: # A raw "[Errno 30] Read-only file system" sends people off to check # folder permissions, which is exactly where the answer is not (#2544). diagnosis = classify_backup_dir_error(e, backup_dir) self._last_backup_at = datetime.now(timezone.utc).isoformat() self._last_status = "failed" self._last_message = diagnosis["message"] logger.error("Local backup failed: %s (%s)", diagnosis["message"], diagnosis["detail"]) return {"success": False, "message": diagnosis["message"], "diagnosis": diagnosis} # Prune old backups retention = max(1, settings["retention"]) self._prune_backups(backup_dir, retention) self._last_backup_at = datetime.now(timezone.utc).isoformat() self._last_status = "success" self._last_message = filename logger.info("Local backup created: %s", zip_path) return {"success": True, "message": "Backup created", "filename": filename} except Exception as e: self._last_backup_at = datetime.now(timezone.utc).isoformat() self._last_status = "failed" self._last_message = str(e) logger.error("Local backup failed: %s", e, exc_info=True) return {"success": False, "message": f"Backup failed: {e}"} finally: self._running = False def _prune_backups(self, backup_dir: Path, retention: int): """Delete oldest backups exceeding the retention count.""" backups = sorted( backup_dir.glob("bambuddy-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True, ) for old_backup in backups[retention:]: try: old_backup.unlink() logger.info("Pruned old backup: %s", old_backup.name) except OSError as e: logger.warning("Could not delete old backup %s: %s", old_backup.name, e) def get_status(self) -> dict: """Return current scheduler status.""" return { "is_running": self._running, "last_backup_at": self._last_backup_at, "last_status": self._last_status, "last_message": self._last_message, "next_run": self._next_run.isoformat() if self._next_run else None, } def resolve_backup_file(self, path_setting: str, filename: str) -> Path | None: """Resolve a backup filename to a full path, with safety checks.""" if "/" in filename or "\\" in filename or ".." in filename: return None if not filename.startswith("bambuddy-backup-") or not filename.endswith(".zip"): return None backup_dir = self._resolve_backup_dir(path_setting) target = ( backup_dir / filename ) # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate if not target.exists(): return None return target def list_backups(self, path_setting: str) -> list[dict]: """List backup ZIP files in the backup directory.""" backup_dir = self._resolve_backup_dir(path_setting) if not backup_dir.exists(): return [] backups = [] for f in sorted(backup_dir.glob("bambuddy-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True): stat = f.stat() backups.append( { "filename": f.name, "size": stat.st_size, "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), } ) return backups def delete_backup(self, path_setting: str, filename: str) -> dict: """Delete a specific backup file. Returns {success, message}.""" # Path traversal protection if "/" in filename or "\\" in filename or ".." in filename: return {"success": False, "message": "Invalid filename"} backup_dir = self._resolve_backup_dir(path_setting) target = ( backup_dir / filename ) # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate below if not target.exists(): return {"success": False, "message": "Backup not found"} if not target.name.startswith("bambuddy-backup-") or not target.name.endswith(".zip"): return {"success": False, "message": "Invalid backup file"} try: target.unlink() return {"success": True, "message": "Backup deleted"} except OSError as e: return {"success": False, "message": f"Could not delete: {e}"} local_backup_service = LocalBackupService()