mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
The Scheduled Local Backups time-of-day picker was interpreted as UTC by _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00 local backup. The UI labeled the field "UTC" but it was still surprising. Picker is now interpreted in the container's local timezone, resolved from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's environment.timezone shows). UTC fallback when TZ is unset or unrecognised. The /local-backup/status endpoint exposes the resolved zone, and the UI renders it next to the field via a new backup.localTimeHint i18n key with real translations in all 10 non-English locales. One-time behaviour change for users who entered a UTC time as a workaround: the first scheduled cycle after upgrade will run at their local TZ offset earlier than expected. Re-enter the time as local once and it is correct from then on. No migration is shipped; migrating around a DST boundary would be ambiguous.
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""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
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _local_zone() -> ZoneInfo:
|
|
"""Resolve the local timezone for scheduled-backup HH:MM interpretation.
|
|
|
|
Uses the container's ``TZ`` env var (the same value the support package
|
|
surfaces); falls back to UTC when unset or unrecognised so a missing TZ
|
|
keeps the legacy behaviour rather than crashing. See #1602 follow-up.
|
|
"""
|
|
tz_name = os.environ.get("TZ", "").strip()
|
|
if not tz_name:
|
|
return ZoneInfo("UTC")
|
|
try:
|
|
return ZoneInfo(tz_name)
|
|
except ZoneInfoNotFoundError:
|
|
logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
|
|
return ZoneInfo("UTC")
|
|
|
|
|
|
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()
|
|
|
|
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"])
|
|
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)
|
|
|
|
# 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()
|