2dd1ca0197
- Add BackupRecord model for tracking backup archives - Add migration 021_add_backup_records - Add backup configuration settings (backup_enabled, backup_dir, backup_remote_destination, backup_remote_folder, backup_retain_hourly/daily/weekly) - Add backup_tasks.py with create_backup, cleanup_old_backups, and helpers - Register hourly/daily/weekly Celery beat schedules - Add /api/admin/backup/* REST endpoints (list, create, download, restore, delete, cleanup) - Add /admin/backup dashboard view and template - Add backup link to admin dropdown navigation in base.html - Add backup settings to SETTING_METADATA in settings_service.py - Add comprehensive test suite (39 tests passing) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
238 lines
7.5 KiB
Python
238 lines
7.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.
|
|
Only SQLite databases are supported.
|
|
|
|
The uploaded file must be a ``.db.gz`` file produced by the DocuElevate
|
|
backup task (a gzip-compressed SQLite ``.dump()`` SQL script).
|
|
"""
|
|
from app.tasks.backup_tasks import _db_path
|
|
|
|
db_path = _db_path()
|
|
if db_path is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Restore is only supported for SQLite databases.",
|
|
)
|
|
|
|
if not file.filename or not file.filename.endswith(".db.gz"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Uploaded file must be a .db.gz backup archive.",
|
|
)
|
|
|
|
import gzip
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
# Write the upload to a temp file first so we can validate it
|
|
with tempfile.NamedTemporaryFile(suffix=".db.gz", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
content = await file.read()
|
|
tmp.write(content)
|
|
|
|
try:
|
|
# Decompress and read SQL statements
|
|
with gzip.open(str(tmp_path), "rt", encoding="utf-8") as gz:
|
|
sql_script = gz.read()
|
|
except Exception as exc:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Failed to decompress backup file: {exc}",
|
|
) from exc
|
|
|
|
# Create a fresh in-memory DB from the script to validate it
|
|
try:
|
|
mem_conn = sqlite3.connect(":memory:")
|
|
mem_conn.executescript(sql_script)
|
|
mem_conn.close()
|
|
except sqlite3.Error as exc:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Backup file contains invalid SQL: {exc}",
|
|
) from exc
|
|
|
|
# Close the application DB session before replacing the file
|
|
db.close()
|
|
|
|
# Preserve the current DB before overwriting
|
|
import shutil
|
|
|
|
bak = str(db_path) + ".pre_restore"
|
|
try:
|
|
shutil.copy2(str(db_path), bak)
|
|
except OSError as exc:
|
|
logger.warning(f"Could not create pre-restore backup at {bak}: {exc}")
|
|
|
|
try:
|
|
# Write the restored database
|
|
restore_conn = sqlite3.connect(str(db_path))
|
|
restore_conn.executescript(sql_script)
|
|
restore_conn.close()
|
|
except sqlite3.Error as exc:
|
|
# Attempt rollback
|
|
try:
|
|
if os.path.exists(bak):
|
|
shutil.copy2(bak, str(db_path))
|
|
except OSError as rollback_exc:
|
|
logger.error(f"Rollback failed; database may be corrupted: {rollback_exc}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Restore failed: {exc}",
|
|
) from exc
|
|
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"}
|