Initial pve cloud backup app
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user