Files
pve-cloud-backups/backend/app/main.py
T
Sonnet 5 1288ba731d
CI / Frontend build (push) Successful in 11s
CI / Script syntax (push) Successful in 4s
CI / Backend tests (push) Successful in 18s
Make proxmox_storage a global setting instead of a per-job snapshot
Backup jobs stored their own proxmox_storage column, copied from
settings at creation time and never resynced. process_backup preferred
that frozen job value, so changing Settings > Proxmox storage had no
effect on existing jobs. Drop the per-job column and always use the
current global setting when starting a backup.

Also copies AGENTS.md to CLAUDE.md.
2026-07-15 12:20:44 +02:00

271 lines
8.0 KiB
Python

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,
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"
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
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,
)