2d754d52ef
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
254 lines
8.5 KiB
Python
254 lines
8.5 KiB
Python
"""
|
||
Backup and restore API endpoints for DocuElevate.
|
||
|
||
Provides REST endpoints for:
|
||
- Listing existing backups
|
||
- Triggering a manual backup
|
||
- Downloading a backup archive
|
||
- Restoring from an uploaded backup file
|
||
- Deleting a backup record
|
||
- Running retention cleanup
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||
from fastapi.responses import FileResponse
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.database import get_db
|
||
from app.models import BackupRecord
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/admin/backup", tags=["backup"])
|
||
|
||
|
||
def _require_admin(request: Request) -> dict:
|
||
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||
user = request.session.get("user")
|
||
if not user or not user.get("is_admin"):
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||
return user
|
||
|
||
|
||
# Annotated shorthand so FastAPI can resolve and tests can override it.
|
||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("/")
|
||
async def list_backups(
|
||
_admin: AdminUser,
|
||
db: Session = Depends(get_db),
|
||
) -> list[dict]:
|
||
"""Return all backup records, newest first."""
|
||
records = db.query(BackupRecord).order_by(BackupRecord.created_at.desc()).all()
|
||
return [
|
||
{
|
||
"id": r.id,
|
||
"filename": r.filename,
|
||
"backup_type": r.backup_type,
|
||
"size_bytes": r.size_bytes,
|
||
"checksum": r.checksum,
|
||
"status": r.status,
|
||
"local_path": r.local_path,
|
||
"remote_destination": r.remote_destination,
|
||
"remote_path": r.remote_path,
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
"local_available": bool(r.local_path and os.path.exists(r.local_path)),
|
||
}
|
||
for r in records
|
||
]
|
||
|
||
|
||
@router.post("/create")
|
||
async def trigger_backup(
|
||
_admin: AdminUser,
|
||
backup_type: str = "hourly",
|
||
) -> dict:
|
||
"""Trigger a manual backup immediately.
|
||
|
||
Query parameter ``backup_type`` accepts ``hourly``, ``daily``, or
|
||
``weekly`` (default: ``hourly``).
|
||
"""
|
||
if backup_type not in ("hourly", "daily", "weekly"):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid backup_type")
|
||
|
||
from app.tasks.backup_tasks import create_backup
|
||
|
||
task = create_backup.delay(backup_type=backup_type)
|
||
return {"task_id": task.id, "status": "queued", "backup_type": backup_type}
|
||
|
||
|
||
@router.get("/{backup_id}/download")
|
||
async def download_backup(
|
||
backup_id: int,
|
||
_admin: AdminUser,
|
||
db: Session = Depends(get_db),
|
||
) -> FileResponse:
|
||
"""Stream the backup archive to the client."""
|
||
rec = db.get(BackupRecord, backup_id)
|
||
if rec is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
|
||
if not rec.local_path or not os.path.exists(rec.local_path):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Local archive file is not available (may have been pruned)",
|
||
)
|
||
return FileResponse(
|
||
path=rec.local_path,
|
||
filename=rec.filename,
|
||
media_type="application/gzip",
|
||
)
|
||
|
||
|
||
@router.post("/restore")
|
||
async def restore_backup(
|
||
_admin: AdminUser,
|
||
file: UploadFile,
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Restore the database from an uploaded gzip-compressed SQL dump.
|
||
|
||
**Warning**: This overwrites the current database contents.
|
||
|
||
Supported formats (must match the currently configured database backend):
|
||
|
||
- ``*.db.gz`` – gzip-compressed SQLite ``.dump()`` SQL script (SQLite backend)
|
||
- ``*.pgsql.gz`` – gzip-compressed ``pg_dump --format=plain`` output (PostgreSQL backend)
|
||
- ``*.mysql.gz`` – gzip-compressed ``mysqldump`` output (MySQL / MariaDB backend)
|
||
"""
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy.engine.url import make_url
|
||
|
||
from app.config import settings as app_settings
|
||
from app.tasks.backup_tasks import (
|
||
_archive_ext_for_backend,
|
||
_db_path,
|
||
_restore_mysql,
|
||
_restore_postgresql,
|
||
_restore_sqlite,
|
||
)
|
||
|
||
url = make_url(app_settings.database_url)
|
||
backend = url.get_backend_name()
|
||
expected_ext = _archive_ext_for_backend(backend)
|
||
|
||
if not file.filename or not file.filename.endswith(expected_ext):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=(
|
||
f"Uploaded file must be a '{expected_ext}' backup archive for the current database backend ({backend})."
|
||
),
|
||
)
|
||
|
||
# Write upload to a temp file
|
||
with tempfile.NamedTemporaryFile(suffix=expected_ext, delete=False) as tmp:
|
||
tmp_path = Path(tmp.name)
|
||
content = await file.read()
|
||
tmp.write(content)
|
||
|
||
try:
|
||
if backend == "sqlite":
|
||
db_path = _db_path()
|
||
if db_path is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Restore is only supported for file-based SQLite databases.",
|
||
)
|
||
# Close the application DB session before replacing the file
|
||
db.close()
|
||
try:
|
||
_restore_sqlite(db_path, tmp_path)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(exc),
|
||
) from exc
|
||
except RuntimeError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=str(exc),
|
||
) from exc
|
||
|
||
elif backend == "postgresql":
|
||
db.close()
|
||
try:
|
||
_restore_postgresql(app_settings.database_url, tmp_path)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"psql binary not found – is PostgreSQL client installed? ({exc})",
|
||
) from exc
|
||
except RuntimeError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"PostgreSQL restore failed: {exc}",
|
||
) from exc
|
||
|
||
elif backend == "mysql":
|
||
db.close()
|
||
try:
|
||
_restore_mysql(app_settings.database_url, tmp_path)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"mysql binary not found – is MySQL client installed? ({exc})",
|
||
) from exc
|
||
except RuntimeError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"MySQL restore failed: {exc}",
|
||
) from exc
|
||
|
||
else:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Database backend '{backend}' does not support restore.",
|
||
)
|
||
|
||
finally:
|
||
tmp_path.unlink(missing_ok=True)
|
||
|
||
logger.info(f"Database restored from uploaded backup: {file.filename}")
|
||
return {"status": "restored", "filename": file.filename}
|
||
|
||
|
||
@router.delete("/{backup_id}")
|
||
async def delete_backup(
|
||
backup_id: int,
|
||
_admin: AdminUser,
|
||
db: Session = Depends(get_db),
|
||
) -> dict:
|
||
"""Delete a backup record (and local file if present)."""
|
||
rec = db.get(BackupRecord, backup_id)
|
||
if rec is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup not found")
|
||
|
||
if rec.local_path and os.path.exists(rec.local_path):
|
||
try:
|
||
os.remove(rec.local_path)
|
||
except OSError as exc:
|
||
logger.warning(f"Could not remove local backup file {rec.local_path}: {exc}")
|
||
|
||
db.delete(rec)
|
||
db.commit()
|
||
return {"status": "deleted", "id": backup_id}
|
||
|
||
|
||
@router.post("/cleanup")
|
||
async def run_cleanup(_admin: AdminUser) -> dict:
|
||
"""Manually trigger the retention cleanup for all backup tiers."""
|
||
from app.tasks.backup_tasks import cleanup_old_backups
|
||
|
||
task = cleanup_old_backups.delay()
|
||
return {"task_id": task.id, "status": "queued"}
|