from typing import Any from fastapi import APIRouter, Depends, HTTPException from ..auth import require_api_key from ..models.backups import ( BackupAlertResponse, BackupReportRequest, BackupRunResponse, ) from ..observability import record_backup_run from ..services.backup_alert_engine import generate_alerts_for_run from ..services.settings_store import SettingsStore, get_settings_store router = APIRouter(prefix="/api/backups", tags=["backups"]) def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str: """Return the service_id for backup attribution. First-wins: if no explicit service_id is given, pick the first enabled ``backups`` service instance (spec R6.1). Returns an empty string when none is configured (backward-compatible with pre-service reports). """ if explicit: return explicit candidates = store.list_services("backups") for svc in candidates: if svc.get("enabled"): return svc["id"] return "" def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]: job = store.get_backup_job_by_name(report.name) if not job: job = store.upsert_backup_job( { "name": report.name, "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, "service_id": service_id, } ) elif report.schedule_interval_seconds: store.upsert_backup_job( { "id": job["id"], "name": report.name, "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, "service_id": service_id, } ) job = store.get_backup_job(job["id"]) return job @router.post("/report") def post_backup_report( report: BackupReportRequest, service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: resolved_service_id = _resolve_backup_service_id(store, service_id) job = _get_or_create_job(store, report, resolved_service_id) # Check for duplicate (same job + started_at within 1s) existing_runs = store.list_backup_runs(job_id=job["id"], limit=5) started_at_ts = int(report.started_at.timestamp()) for existing in existing_runs: if abs(existing["started_at"] - started_at_ts) <= 1: return BackupRunResponse(**existing) run_data = { "job_id": job["id"], "started_at": started_at_ts, "ended_at": int(report.ended_at.timestamp()) if report.ended_at else None, "status": report.status, "bytes_transferred": report.bytes_transferred, "duration_ms": report.duration_ms, "error_message": report.error_message, "details": report.details, } run = store.create_backup_run(run_data) record_backup_run(job_name=report.name, status=report.status, success=report.status == "success") # Generate alerts previous_runs = store.list_backup_runs(job_id=job["id"], status="success", limit=20) alerts = generate_alerts_for_run(run, previous_runs, job) for alert in alerts: store.create_backup_alert(alert) # Resolve old alerts of the same type if this run is successful if report.status == "success": store.resolve_backup_alerts_for_job(job["id"], "failed_status") store.resolve_backup_alerts_for_job(job["id"], "anomaly_size") store.resolve_backup_alerts_for_job(job["id"], "anomaly_duration") # Map details -> details_json for response model run["details_json"] = run.pop("details", None) return BackupRunResponse(**run) @router.post("/report/start") def post_backup_start( report: BackupReportRequest, service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: resolved_service_id = _resolve_backup_service_id(store, service_id) job = _get_or_create_job(store, report, resolved_service_id) run_data = { "job_id": job["id"], "started_at": int(report.started_at.timestamp()), "status": "in_progress", } run = store.create_backup_run(run_data) record_backup_run(job_name=report.name, status="in_progress") # Map details -> details_json for response model run["details_json"] = run.pop("details", None) return BackupRunResponse(**run) @router.get("/jobs") def get_backup_jobs( store: SettingsStore = Depends(get_settings_store), ) -> list[dict[str, Any]]: jobs = store.list_backup_jobs() return jobs @router.get("/jobs/{job_id}") def get_backup_job( job_id: str, store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: job = store.get_backup_job(job_id) if not job: raise HTTPException(status_code=404, detail="Backup job not found") runs = store.list_backup_runs(job_id=job_id, limit=20) return { "job": job, "runs": runs, } @router.get("/runs") def get_backup_runs( job_id: str | None = None, status: str | None = None, limit: int = 50, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupRunResponse]: runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit) return [BackupRunResponse(**run) for run in runs] @router.get("/runs/{run_id}") def get_backup_run( run_id: str, store: SettingsStore = Depends(get_settings_store), ) -> BackupRunResponse: run = store.get_backup_run(run_id) if not run: raise HTTPException(status_code=404, detail="Backup run not found") return BackupRunResponse(**run) @router.get("/alerts") def get_backup_alerts( job_id: str | None = None, acknowledged: bool | None = None, severity: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupAlertResponse]: alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity) return [BackupAlertResponse(**alert) for alert in alerts] @router.post("/alerts/{alert_id}/acknowledge") def acknowledge_backup_alert( alert_id: str, store: SettingsStore = Depends(get_settings_store), ) -> BackupAlertResponse: alert = store.acknowledge_backup_alert(alert_id) if not alert: raise HTTPException(status_code=404, detail="Alert not found") return BackupAlertResponse(**alert)