273 lines
8.1 KiB
Python
273 lines
8.1 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
|
|
proxmox_storage: 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
|
|
proxmox_storage: 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 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,
|
|
)
|