local_backup.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Scheduled local backup service.
  2. Creates ZIP snapshots of the full Bambuddy data (database + data directories)
  3. on a configurable schedule with retention management.
  4. """
  5. import asyncio
  6. import logging
  7. from datetime import datetime, timedelta, timezone
  8. from pathlib import Path
  9. from sqlalchemy import select
  10. from backend.app.core.config import settings as app_settings
  11. from backend.app.core.database import async_session
  12. from backend.app.models.settings import Settings
  13. from backend.app.services.backup_path import classify_backup_dir_error, probe_backup_dir
  14. # The TZ-env resolution used to live here. It moved to utils/local_time when the
  15. # smart-plug energy history (#2539) needed the same local day boundary. Re-exported
  16. # under the old private name so existing importers keep working.
  17. from backend.app.utils.local_time import local_zone as _local_zone
  18. logger = logging.getLogger(__name__)
  19. SCHEDULE_INTERVALS = {
  20. "hourly": 3600,
  21. "daily": 86400,
  22. "weekly": 604800,
  23. }
  24. def _default_backup_dir() -> Path:
  25. return app_settings.base_dir / "backups"
  26. class LocalBackupService:
  27. """Manages scheduled local backup snapshots with retention."""
  28. def __init__(self):
  29. self._scheduler_task: asyncio.Task | None = None
  30. self._check_interval = 60
  31. self._running: bool = False
  32. self._last_backup_at: str | None = None
  33. self._last_status: str | None = None
  34. self._last_message: str | None = None
  35. self._next_run: datetime | None = None
  36. async def start_scheduler(self):
  37. """Start the background scheduler loop."""
  38. if self._scheduler_task is not None:
  39. return
  40. logger.info("Starting local backup scheduler")
  41. # Seed next_run from settings so the first check has a target
  42. await self._seed_next_run()
  43. self._scheduler_task = asyncio.create_task(self._scheduler_loop())
  44. def stop_scheduler(self):
  45. """Stop the scheduler."""
  46. if self._scheduler_task:
  47. self._scheduler_task.cancel()
  48. self._scheduler_task = None
  49. logger.info("Stopped local backup scheduler")
  50. async def _scheduler_loop(self):
  51. """Main scheduler loop — checks for due backups every minute."""
  52. while True:
  53. try:
  54. await asyncio.sleep(self._check_interval)
  55. await self._check_scheduled_backup()
  56. except asyncio.CancelledError:
  57. break
  58. except Exception as e:
  59. logger.error("Error in local backup scheduler: %s", e)
  60. await asyncio.sleep(60)
  61. async def _seed_next_run(self):
  62. """Load settings and calculate initial next_run."""
  63. try:
  64. settings = await self._load_settings()
  65. if settings.get("enabled"):
  66. self._next_run = self._calculate_next_run(
  67. settings.get("schedule", "daily"),
  68. settings.get("time", "03:00"),
  69. )
  70. except Exception as e:
  71. logger.debug("Could not seed local backup next_run: %s", e)
  72. async def _load_settings(self) -> dict:
  73. """Read local backup settings from the DB."""
  74. async with async_session() as db:
  75. keys = [
  76. "local_backup_enabled",
  77. "local_backup_schedule",
  78. "local_backup_time",
  79. "local_backup_retention",
  80. "local_backup_path",
  81. ]
  82. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  83. rows = {r.key: r.value for r in result.scalars().all()}
  84. return {
  85. "enabled": rows.get("local_backup_enabled", "false").lower() == "true",
  86. "schedule": rows.get("local_backup_schedule", "daily"),
  87. "time": rows.get("local_backup_time", "03:00"),
  88. "retention": int(rows.get("local_backup_retention", "5")),
  89. "path": rows.get("local_backup_path", ""),
  90. }
  91. async def _check_scheduled_backup(self):
  92. """Check if a scheduled backup is due and run it."""
  93. settings = await self._load_settings()
  94. if not settings["enabled"]:
  95. self._next_run = None
  96. return
  97. now = datetime.now(timezone.utc)
  98. # If no next_run set, schedule one
  99. if self._next_run is None:
  100. self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
  101. return
  102. if self._next_run <= now:
  103. logger.info("Running scheduled local backup")
  104. await self.run_backup(settings)
  105. self._next_run = self._calculate_next_run(settings["schedule"], settings["time"])
  106. def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
  107. """Calculate the next scheduled run time.
  108. For hourly: next full hour (timezone-agnostic).
  109. For daily/weekly: next occurrence of the configured HH:MM, interpreted
  110. in the container's local timezone (TZ env var, UTC fallback). Returns
  111. a UTC-aware datetime for storage / comparison against ``now``.
  112. """
  113. now_utc = datetime.now(timezone.utc)
  114. if schedule_type == "hourly":
  115. # Next full hour
  116. next_run = now_utc.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
  117. return next_run
  118. # Parse HH:MM time
  119. try:
  120. parts = time_str.strip().split(":")
  121. hour = int(parts[0])
  122. minute = int(parts[1]) if len(parts) > 1 else 0
  123. except (ValueError, IndexError):
  124. hour, minute = 3, 0
  125. local_tz = _local_zone()
  126. now_local = now_utc.astimezone(local_tz)
  127. # Next occurrence of HH:MM local time, today or tomorrow.
  128. # ``fold=0`` resolves the ambiguous wall-clock window at DST fall-back
  129. # to the earlier instance (consistent with cron's behaviour). On the
  130. # spring-forward gap the synthesized local time will normalise to the
  131. # next valid instant when converted to UTC.
  132. next_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0)
  133. if next_local <= now_local:
  134. next_local += timedelta(days=1)
  135. if schedule_type == "weekly":
  136. next_local += timedelta(weeks=1)
  137. return next_local.astimezone(timezone.utc)
  138. def _resolve_backup_dir(self, path_setting: str) -> Path:
  139. """Resolve the backup output directory from settings."""
  140. if path_setting.strip():
  141. return Path(path_setting.strip())
  142. return _default_backup_dir()
  143. def check_path(self, path_setting: str) -> dict:
  144. """Probe the configured output directory with a real write.
  145. Called when the path is saved and when the backup card is opened, so a
  146. directory the service cannot write to is caught there and then instead
  147. of at 03:00 for a week (#2544).
  148. """
  149. return probe_backup_dir(self._resolve_backup_dir(path_setting))
  150. async def run_backup(self, settings: dict | None = None) -> dict:
  151. """Run a backup now. Returns {success, message, filename}."""
  152. if self._running:
  153. return {"success": False, "message": "Backup already in progress"}
  154. self._running = True
  155. try:
  156. if settings is None:
  157. settings = await self._load_settings()
  158. backup_dir = self._resolve_backup_dir(settings["path"])
  159. try:
  160. backup_dir.mkdir(parents=True, exist_ok=True)
  161. from backend.app.api.routes.settings import create_backup_zip
  162. zip_path, filename = await create_backup_zip(output_path=backup_dir)
  163. except OSError as e:
  164. # A raw "[Errno 30] Read-only file system" sends people off to check
  165. # folder permissions, which is exactly where the answer is not (#2544).
  166. diagnosis = classify_backup_dir_error(e, backup_dir)
  167. self._last_backup_at = datetime.now(timezone.utc).isoformat()
  168. self._last_status = "failed"
  169. self._last_message = diagnosis["message"]
  170. logger.error("Local backup failed: %s (%s)", diagnosis["message"], diagnosis["detail"])
  171. return {"success": False, "message": diagnosis["message"], "diagnosis": diagnosis}
  172. # Prune old backups
  173. retention = max(1, settings["retention"])
  174. self._prune_backups(backup_dir, retention)
  175. self._last_backup_at = datetime.now(timezone.utc).isoformat()
  176. self._last_status = "success"
  177. self._last_message = filename
  178. logger.info("Local backup created: %s", zip_path)
  179. return {"success": True, "message": "Backup created", "filename": filename}
  180. except Exception as e:
  181. self._last_backup_at = datetime.now(timezone.utc).isoformat()
  182. self._last_status = "failed"
  183. self._last_message = str(e)
  184. logger.error("Local backup failed: %s", e, exc_info=True)
  185. return {"success": False, "message": f"Backup failed: {e}"}
  186. finally:
  187. self._running = False
  188. def _prune_backups(self, backup_dir: Path, retention: int):
  189. """Delete oldest backups exceeding the retention count."""
  190. backups = sorted(
  191. backup_dir.glob("bambuddy-backup-*.zip"),
  192. key=lambda p: p.stat().st_mtime,
  193. reverse=True,
  194. )
  195. for old_backup in backups[retention:]:
  196. try:
  197. old_backup.unlink()
  198. logger.info("Pruned old backup: %s", old_backup.name)
  199. except OSError as e:
  200. logger.warning("Could not delete old backup %s: %s", old_backup.name, e)
  201. def get_status(self) -> dict:
  202. """Return current scheduler status."""
  203. return {
  204. "is_running": self._running,
  205. "last_backup_at": self._last_backup_at,
  206. "last_status": self._last_status,
  207. "last_message": self._last_message,
  208. "next_run": self._next_run.isoformat() if self._next_run else None,
  209. }
  210. def resolve_backup_file(self, path_setting: str, filename: str) -> Path | None:
  211. """Resolve a backup filename to a full path, with safety checks."""
  212. if "/" in filename or "\\" in filename or ".." in filename:
  213. return None
  214. if not filename.startswith("bambuddy-backup-") or not filename.endswith(".zip"):
  215. return None
  216. backup_dir = self._resolve_backup_dir(path_setting)
  217. target = (
  218. backup_dir / filename
  219. ) # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate
  220. if not target.exists():
  221. return None
  222. return target
  223. def list_backups(self, path_setting: str) -> list[dict]:
  224. """List backup ZIP files in the backup directory."""
  225. backup_dir = self._resolve_backup_dir(path_setting)
  226. if not backup_dir.exists():
  227. return []
  228. backups = []
  229. for f in sorted(backup_dir.glob("bambuddy-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True):
  230. stat = f.stat()
  231. backups.append(
  232. {
  233. "filename": f.name,
  234. "size": stat.st_size,
  235. "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
  236. }
  237. )
  238. return backups
  239. def delete_backup(self, path_setting: str, filename: str) -> dict:
  240. """Delete a specific backup file. Returns {success, message}."""
  241. # Path traversal protection
  242. if "/" in filename or "\\" in filename or ".." in filename:
  243. return {"success": False, "message": "Invalid filename"}
  244. backup_dir = self._resolve_backup_dir(path_setting)
  245. target = (
  246. backup_dir / filename
  247. ) # SEC-PATH-OK: filename rejected above on /, \\, .., plus startswith "bambuddy-backup-" + endswith ".zip" gate below
  248. if not target.exists():
  249. return {"success": False, "message": "Backup not found"}
  250. if not target.name.startswith("bambuddy-backup-") or not target.name.endswith(".zip"):
  251. return {"success": False, "message": "Invalid backup file"}
  252. try:
  253. target.unlink()
  254. return {"success": True, "message": "Backup deleted"}
  255. except OSError as e:
  256. return {"success": False, "message": f"Could not delete: {e}"}
  257. local_backup_service = LocalBackupService()