from __future__ import annotations import socket from pathlib import Path from typing import Callable from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from . import commands, jobs from .backup_service import ( dashboard, delete_backup_manually, delete_job_with_backups, delete_local_copy, download_backup_locally, get_backup, list_backups, process_backup, queue_backup_for_job, ) from .config import STATIC_DIR, ensure_dirs from .migrations import run_migrations from .settings_store import get_settings, set_settings class SettingsPayload(BaseModel): proxmox_node: str = "" proxmox_storage: str = "" local_backup_dir: str = "" rclone_path: str = "" rclone_remote: str = "" rclone_remote_path: str = "pve-cloud-backup" discord_webhook_url: str = "" allowed_cors_origins: list[str] = Field(default_factory=list) default_compression: str = "zstd" default_backup_mode: str = "snapshot" default_retention_type: str = "days" default_retention_value: int = 30 max_concurrent_backups: int = 1 timezone: str = "UTC" keep_local_backups: bool = False setup_complete: bool = False class JobPayload(BaseModel): guest_vmid: int guest_name: str guest_type: str node: str enabled: bool = True cron_schedule: str backup_mode: str compression: str retention_type: str retention_value: int class PartialJobPayload(BaseModel): guest_vmid: int | None = None guest_name: str | None = None guest_type: str | None = None node: str | None = None enabled: bool | None = None cron_schedule: str | None = None backup_mode: str | None = None compression: str | None = None retention_type: str | None = None retention_value: int | None = None app = FastAPI(title="PVE Cloud Backup") @app.middleware("http") async def dynamic_cors(request: Request, call_next: Callable): origin = request.headers.get("origin") allowed = get_settings().get("allowed_cors_origins", []) is_allowed = origin and ("*" in allowed or origin in allowed) if request.method == "OPTIONS": response = Response(status_code=204) else: response = await call_next(request) if is_allowed: response.headers["Access-Control-Allow-Origin"] = origin response.headers["Vary"] = "Origin" response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE,OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization" return response @app.on_event("startup") async def startup() -> None: ensure_dirs() run_migrations() @app.get("/api/setup/status") def setup_status(): settings = get_settings() return {"setup_complete": settings["setup_complete"]} @app.get("/api/discover") def discover(): settings = get_settings() rclone_path = settings.get("rclone_path") or commands.find_executable("rclone") remotes: list[str] = [] if rclone_path: try: remotes = commands.rclone_list_remotes(rclone_path) except Exception: remotes = [] storages: list[str] = [] try: storages = commands.list_storages(settings.get("proxmox_node") or None) except Exception: storages = [] return { "proxmox_node": settings.get("proxmox_node") or socket.gethostname(), "rclone_path": rclone_path, "rclone_remotes": remotes, "proxmox_storages": storages, } def _validate_settings(payload: dict) -> None: try: ZoneInfo(payload.get("timezone") or "UTC") except (ValueError, ZoneInfoNotFoundError) as exc: raise HTTPException(400, "Timezone must be a valid IANA timezone, for example Europe/Berlin or UTC") from exc if payload.get("setup_complete"): missing = [ key for key in ("proxmox_node", "proxmox_storage", "local_backup_dir", "rclone_path", "rclone_remote") if not payload.get(key) ] if missing: raise HTTPException(400, f"Missing required settings: {', '.join(missing)}") try: remotes = commands.rclone_list_remotes(payload["rclone_path"]) except Exception as exc: raise HTTPException(400, f"rclone is not usable: {exc}") from exc if payload["rclone_remote"].rstrip(":") not in remotes: raise HTTPException(400, "Configured rclone remote was not found. Configure rclone first, then rerun setup.") if payload.get("max_concurrent_backups", 1) < 1: raise HTTPException(400, "Maximum concurrent backups must be at least 1") @app.get("/api/settings") def read_settings(): return get_settings() @app.put("/api/settings") def write_settings(payload: SettingsPayload): data = payload.model_dump() _validate_settings(data) old_timezone = get_settings().get("timezone") try: saved = set_settings(data) except ValueError as exc: raise HTTPException(400, str(exc)) from exc if saved.get("timezone") != old_timezone: jobs.recalculate_next_runs() return get_settings() @app.get("/api/dashboard") def read_dashboard(): return dashboard() @app.get("/api/guests") def read_guests(): try: return commands.list_guests() except Exception as exc: raise HTTPException(502, f"Failed to list Proxmox guests via pvesh: {exc}") from exc @app.get("/api/jobs") def read_jobs(): return jobs.list_jobs() @app.post("/api/jobs") def create_job(payload: JobPayload): try: return jobs.create_job(payload.model_dump()) except ValueError as exc: raise HTTPException(400, str(exc)) from exc @app.put("/api/jobs/{job_id}") def update_job(job_id: int, payload: PartialJobPayload): data = {key: value for key, value in payload.model_dump().items() if value is not None} try: return jobs.update_job(job_id, data) except KeyError as exc: raise HTTPException(404, str(exc)) from exc except ValueError as exc: raise HTTPException(400, str(exc)) from exc @app.delete("/api/jobs/{job_id}") async def delete_job(job_id: int): try: result = await delete_job_with_backups(job_id) return {"ok": True, **result} except KeyError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(409, str(exc)) from exc @app.post("/api/jobs/{job_id}/run") async def run_job_now(job_id: int): try: job = jobs.get_job(job_id) except KeyError as exc: raise HTTPException(404, str(exc)) from exc queued = queue_backup_for_job(job) if not queued: raise HTTPException(409, "This guest already has an active backup") import asyncio asyncio.create_task(process_backup(queued["id"])) return queued @app.get("/api/backups") def read_backups(guest_vmid: int | None = None, state: str | None = None): return list_backups(guest_vmid=guest_vmid, state=state) @app.get("/api/backups/{backup_id}") def read_backup(backup_id: str): try: return get_backup(backup_id) except KeyError as exc: raise HTTPException(404, str(exc)) from exc @app.delete("/api/backups/{backup_id}") async def delete_backup(backup_id: str): try: return await delete_backup_manually(backup_id) except KeyError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(409, str(exc)) from exc @app.post("/api/backups/{backup_id}/download") async def download_backup(backup_id: str): try: return await download_backup_locally(backup_id) except KeyError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(409, str(exc)) from exc except Exception as exc: raise HTTPException(502, f"Download failed: {exc}") from exc @app.delete("/api/backups/{backup_id}/local") async def delete_backup_local_copy(backup_id: str): try: return await delete_local_copy(backup_id) except KeyError as exc: raise HTTPException(404, str(exc)) from exc except RuntimeError as exc: raise HTTPException(409, str(exc)) from exc if (STATIC_DIR / "assets").exists(): app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") @app.get("/{full_path:path}") def spa(full_path: str): index = STATIC_DIR / "index.html" requested = STATIC_DIR / full_path if full_path and requested.exists() and requested.is_file(): return FileResponse(requested) if index.exists(): return FileResponse(index) return JSONResponse( {"detail": "Frontend has not been built yet. Run /opt/pve-cloud-backup/scripts/install.sh."}, status_code=503, )