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

125 lines
4.5 KiB
Python

"""API routes for scheduled local backups."""
import logging
from fastapi import APIRouter, Path
from fastapi.responses import FileResponse, JSONResponse
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.models.user import User
from backend.app.services.local_backup import local_backup_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/local-backup", tags=["local-backup"])
@router.get("/status")
async def get_status(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Get local backup scheduler status and configuration."""
from backend.app.services.local_backup import _local_zone
settings = await local_backup_service._load_settings()
status = local_backup_service.get_status()
return {
**status,
"enabled": settings["enabled"],
"schedule": settings["schedule"],
"time": settings["time"],
"retention": settings["retention"],
"path": settings["path"],
"default_path": str(local_backup_service._resolve_backup_dir("")),
# IANA zone name the HH:MM picker is interpreted in (TZ env, UTC fallback).
# Frontend renders this next to the time field so users see the same
# zone the backend will use. #1602 follow-up.
"timezone": str(_local_zone()),
}
@router.get("/path-check")
async def check_path(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Check that the configured output directory can actually be written to.
Writes and removes a probe file. A path the service cannot write to — a NAS
share outside the systemd unit's ReadWritePaths, say — otherwise only shows
up as a failed backup hours later (#2544).
"""
settings = await local_backup_service._load_settings()
return local_backup_service.check_path(settings["path"])
@router.post("/run")
async def trigger_backup(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Trigger a local backup immediately."""
result = await local_backup_service.run_backup()
return result
@router.get("/backups")
async def list_backups(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""List existing backup files."""
settings = await local_backup_service._load_settings()
return local_backup_service.list_backups(settings["path"])
@router.get("/backups/{filename}/download")
async def download_backup(
filename: str = Path(..., description="Backup filename to download"),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Download a specific backup file."""
settings = await local_backup_service._load_settings()
file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
if file_path is None:
return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
return FileResponse(
path=file_path,
filename=filename,
media_type="application/zip",
)
@router.post("/backups/{filename}/restore")
async def restore_backup(
filename: str = Path(..., description="Backup filename to restore"),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
):
"""Restore from a scheduled backup file on the server."""
import io
from fastapi import UploadFile
from fastapi.responses import JSONResponse
settings = await local_backup_service._load_settings()
file_path = local_backup_service.resolve_backup_file(settings["path"], filename)
if file_path is None:
return JSONResponse(status_code=404, content={"success": False, "message": "Backup not found"})
from backend.app.api.routes.settings import restore_backup as settings_restore_backup
from backend.app.core.database import async_session
content = file_path.read_bytes()
upload = UploadFile(filename=filename, file=io.BytesIO(content))
async with async_session() as db:
return await settings_restore_backup(file=upload, db=db)
@router.delete("/backups/{filename}")
async def delete_backup(
filename: str = Path(..., description="Backup filename to delete"),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
):
"""Delete a specific backup file."""
settings = await local_backup_service._load_settings()
return local_backup_service.delete_backup(settings["path"], filename)