Files
maziggy 5bbfeefa65 fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
2026-07-12 08:44:53 +02:00

309 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
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()