Files
pvemanager/backend/app/api/proxmox/backup.py
markmorado 31d17fca61
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m20s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Has been cancelled
feat: scan NFS server for available exports in storage-add dialog
Add a "scan exports" button next to the server field for NFS storages
that calls Proxmox's /nodes/{node}/scan/nfs and turns the export path
input into a select populated with the real exports found on the
target server, instead of requiring the path to be typed by hand.
2026-08-20 10:23:04 +05:00

919 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Backup & Storage API
====================
Endpoints for managing Proxmox storages, backup files,
creating backups (vzdump), restoring, and scheduled backup jobs.
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from loguru import logger
from ...db import get_db
from ...models import BackupJob, ProxmoxServer, User
from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
from .tasks import ProxmoxTaskService
router = APIRouter()
# ==================== Storage Management ====================
@router.get("/api/backups/storages/{server_id}")
def get_storages(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List all storages configured on a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
storages = client.get_cluster_storages()
# Конфиг кластера общий для всех нод и ничего не говорит о том, поднялось ли
# хранилище на конкретной ноде. Доступность берём из /cluster/resources —
# один запрос на весь кластер.
node_status = client.get_storage_node_status()
for s in storages:
statuses = node_status.get(s.get("storage"), [])
s["nodes_status"] = statuses
s["inactive_nodes"] = [n["node"] for n in statuses if not n["active"]]
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error listing storages for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/backups/scan-nfs/{server_id}")
def scan_nfs(
server_id: int,
server: str,
node: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Scan an NFS server for available exports, to populate the storage-add dialog."""
pve_server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not pve_server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(pve_server)
target_node = node
if not target_node:
nodes = client.get_nodes()
if not nodes:
raise HTTPException(status_code=400, detail="No nodes available to scan from")
target_node = nodes[0]["node"]
exports = client.scan_nfs_exports(target_node, server)
return JSONResponse(content={"exports": exports})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error scanning NFS exports on {server} for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/storages/{server_id}")
async def create_storage(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Add a new storage to a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
storage_id = data.pop("storage", None)
storage_type = data.pop("type", None)
if not storage_id or not storage_type:
raise HTTPException(status_code=400, detail="storage and type are required")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_storage, storage_id, storage_type, **data)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="storage_create", resource_type="storage",
resource_id=storage_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/backups/storages/{server_id}/{storage_id}")
async def update_storage(
server_id: int,
storage_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Update storage configuration"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.update_storage, storage_id, **data)
if result.get("success"):
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/storages/{server_id}/{storage_id}")
def delete_storage(
server_id: int,
storage_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Delete storage from Proxmox"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_storage(storage_id)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="storage_delete", resource_type="storage",
resource_id=storage_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Backup Listing ====================
@router.get("/api/backups/list/{server_id}")
def list_backups(
server_id: int,
storage: str,
node: Optional[str] = None,
vmid: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List backup files stored in a given storage.
If ``node`` is omitted (or empty), backup files are aggregated from all
cluster nodes that have the storage available. Duplicate ``volid`` entries
(which appear on shared storages) are de-duplicated.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
nodes_to_query: list[str] = []
if node:
nodes_to_query = [node]
else:
try:
cluster_nodes = client.get_nodes() or []
nodes_to_query = [
n.get("node") for n in cluster_nodes
if n.get("node") and (n.get("status") in (None, "online"))
]
except Exception as e:
logger.warning(f"Failed to enumerate cluster nodes for backup listing: {e}")
seen: set[str] = set()
backups: list[dict] = []
for n in nodes_to_query:
try:
items = client.list_backups(n, storage, vmid) or []
except Exception as e:
logger.warning(f"Skipping node {n} when listing backups: {e}")
continue
for item in items:
volid = item.get("volid")
if volid and volid in seen:
continue
if volid:
seen.add(volid)
# annotate with the node where it was discovered (helpful for
# restore / delete actions on non-shared storages)
item.setdefault("node", n)
backups.append(item)
return JSONResponse(content={"backups": backups})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing backups: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/{server_id}/backup")
async def delete_backup(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:delete")),
):
"""Delete a specific backup file"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
node = data.get("node")
storage = data.get("storage")
volid = data.get("volid")
if not all([node, storage, volid]):
raise HTTPException(status_code=400, detail="node, storage, volid are required")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.delete_backup, node, storage, volid)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_delete", resource_type="backup",
resource_id=volid, username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Create Backup ====================
@router.post("/api/backups/create")
async def create_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:create")),
):
"""Trigger vzdump backup for a VM/container. Returns UPID for tracking."""
data = await request.json()
server_id = data.get("server_id")
node = data.get("node")
vmid = data.get("vmid")
storage = data.get("storage")
if not all([server_id, node, vmid, storage]):
raise HTTPException(status_code=400, detail="server_id, node, vmid, storage are required")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
mode = data.get("mode", "snapshot")
compress = data.get("compress", "zstd")
remove = int(data.get("remove", 1))
notes = data.get("notes")
keep_last = data.get("keep_last")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(
client.create_backup,
node=node, vmid=int(vmid), storage=storage,
mode=mode, compress=compress, remove=remove,
keep_last=keep_last, notes=notes,
)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_create", resource_type="backup",
resource_id=str(vmid), username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
details={"storage": storage, "mode": mode, "compress": compress},
)
return JSONResponse(content={"success": True, "upid": result.get("upid")})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Task Status ====================
@router.get("/api/backups/task/{server_id}/{node}/{upid:path}")
def get_task_status(
server_id: int,
node: str,
upid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""Get Proxmox task status and log by UPID"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
status = client.get_task_status(node, upid)
log = client.get_task_log(node, upid)
return JSONResponse(content={"status": status, "log": log})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Restore ====================
@router.post("/api/backups/restore")
async def restore_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:restore")),
):
"""Restore a VM or LXC from a backup file. Returns UPID."""
data = await request.json()
server_id = data.get("server_id")
node = data.get("node")
vmid = data.get("vmid")
archive = data.get("archive") # volume id
# Target storage for the restored disks. Empty => "from backup configuration"
# (disks go to the storage recorded in the backup). NOT the backup storage.
storage = data.get("storage") or None
vm_type = data.get("vm_type", "qemu")
new_vmid = data.get("new_vmid")
start = bool(data.get("start", False))
unique = bool(data.get("unique", True))
if not all([server_id, node, vmid, archive]):
raise HTTPException(
status_code=400,
detail="server_id, node, vmid, archive are required",
)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
if vm_type == "lxc":
result = await _run_in_executor(
client.restore_lxc,
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start,
)
else:
result = await _run_in_executor(
client.restore_vm,
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start, unique=unique,
)
if result.get("success"):
target_vmid = int(new_vmid) if new_vmid else int(vmid)
upid = result.get("upid")
# Track the restore as a Proxmox UPID task so it shows up on the Tasks
# page and the poller can report live progress / final status.
if upid:
try:
ptask = ProxmoxTaskService.register(
db=db, upid=upid, server_id=server_id, user_id=current_user.id,
action="backup_restore", node=node, vmid=target_vmid,
vm_type=vm_type, description=f"Восстановление {vm_type.upper()} {target_vmid}",
)
ptask.progress = 0
db.commit()
except Exception as _te:
logger.warning(f"Could not register restore task: {_te}")
LoggingService.log_proxmox_action(
db=db, action="backup_restore", resource_type="backup",
resource_id=str(vmid), username=current_user.username,
server_id=server_id, server_name=server.name,
node_name=node, success=True,
details={"archive": archive, "storage": storage, "vm_type": vm_type},
)
return JSONResponse(content={"success": True, "upid": upid})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/restore-bulk")
async def restore_backup_bulk(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:restore")),
):
"""Restore several backups at once.
Body:
server_id (int), storage (str)
mode: "in_place" (overwrite original VMID, force) | "new_vmid" (clone to fresh VMIDs)
start (bool): start each guest after restore
items: [{node, vmid, archive, vm_type}] — expected one archive per distinct VMID
Returns a per-item result list with UPIDs / errors.
"""
data = await request.json()
server_id = data.get("server_id")
# Target storage for restored disks. Empty => "from backup configuration".
storage = data.get("storage") or None
mode = data.get("mode", "in_place")
start = bool(data.get("start", False))
items = data.get("items", [])
if not server_id or not items:
raise HTTPException(status_code=400, detail="server_id and items are required")
if mode not in ("in_place", "new_vmid"):
raise HTTPException(status_code=400, detail="mode must be 'in_place' or 'new_vmid'")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
client = _get_proxmox_client(server)
allocated: set = set()
def alloc_vmid(start_from: int):
"""Find the next free VMID at/above start_from, skipping ones already handed out."""
candidate = start_from
for _ in range(10000):
if candidate in allocated:
candidate += 1
continue
try:
# /cluster/nextid?vmid=X returns X if free, raises if taken
ok = client.proxmox.cluster.nextid.get(vmid=candidate)
chosen = int(ok) if ok else candidate
allocated.add(chosen)
return chosen
except Exception:
candidate += 1
return None
next_start = None
if mode == "new_vmid":
next_start = await _run_in_executor(client.get_next_vmid) or 100
results = []
for it in items:
node = it.get("node")
vmid = it.get("vmid")
archive = it.get("archive")
vm_type = it.get("vm_type", "qemu")
if not node or not vmid or not archive:
results.append({"vmid": vmid, "archive": archive, "success": False,
"error": "node, vmid and archive are required"})
continue
new_vmid = None
if mode == "new_vmid":
new_vmid = await _run_in_executor(alloc_vmid, next_start)
if not new_vmid:
results.append({"vmid": vmid, "archive": archive, "success": False,
"error": "No free VMID available"})
continue
next_start = new_vmid + 1
force = (mode == "in_place")
try:
if vm_type == "lxc":
r = await _run_in_executor(
client.restore_lxc,
node=node, vmid=int(vmid), archive=archive, storage=storage,
new_vmid=new_vmid, start=start, force=force,
)
else:
r = await _run_in_executor(
client.restore_vm,
node=node, vmid=int(vmid), archive=archive, storage=storage,
new_vmid=new_vmid, start=start, unique=(mode == "new_vmid"), force=force,
)
target = new_vmid or int(vmid)
if r.get("success"):
results.append({"vmid": vmid, "target_vmid": target, "archive": archive,
"success": True, "upid": r.get("upid")})
upid = r.get("upid")
if upid:
try:
ptask = ProxmoxTaskService.register(
db=db, upid=upid, server_id=server_id, user_id=current_user.id,
action="backup_restore", node=node, vmid=target,
vm_type=vm_type, description=f"Восстановление {vm_type.upper()} {target}",
)
ptask.progress = 0
db.commit()
except Exception as _te:
logger.warning(f"Could not register bulk restore task: {_te}")
LoggingService.log_proxmox_action(
db=db, action="backup_restore", resource_type="backup",
resource_id=str(target), username=current_user.username,
server_id=server_id, server_name=server.name, node_name=node, success=True,
details={"archive": archive, "storage": storage, "vm_type": vm_type,
"mode": mode, "bulk": True},
)
else:
results.append({"vmid": vmid, "target_vmid": target, "archive": archive,
"success": False, "error": r.get("error", "Failed")})
except Exception as e:
results.append({"vmid": vmid, "archive": archive, "success": False, "error": str(e)})
succeeded = sum(1 for r in results if r["success"])
return JSONResponse(content={
"success": succeeded > 0,
"total": len(results),
"succeeded": succeeded,
"failed": len(results) - succeeded,
"results": results,
})
# ==================== Scheduled Backup Jobs ====================
@router.get("/api/backups/proxmox-jobs/{server_id}")
def list_proxmox_native_jobs(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""Fetch native vzdump backup schedules configured directly in Proxmox"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
jobs = client.get_cluster_backup_jobs()
return JSONResponse(content={"jobs": jobs, "server_name": server.name})
except Exception as e:
logger.error(f"Error fetching proxmox native jobs for server {server_id}: {e}")
return JSONResponse(content={"jobs": [], "error": str(e)})
def _build_native_job_props(data: dict, *, for_create: bool) -> dict:
"""Собрать параметры vzdump для POST/PUT /cluster/backup из тела запроса."""
props: dict = {}
schedule = data.get("schedule")
if schedule:
props["schedule"] = schedule
storage = data.get("storage")
if storage:
props["storage"] = storage
if data.get("mode"):
props["mode"] = data["mode"]
if data.get("compress") is not None:
props["compress"] = data["compress"]
props["enabled"] = 1 if data.get("enabled", True) else 0
if data.get("comment"):
props["comment"] = data["comment"]
if data.get("mailto"):
props["mailto"] = data["mailto"]
if data.get("notes_template"):
props["notes-template"] = data["notes_template"]
if data.get("node"):
props["node"] = data["node"]
# Выбор гостей: все / список VMID / пул. Взаимоисключающие поля в PVE —
# при обновлении чистим неиспользуемые через delete, иначе PVE ругается.
selection = data.get("selection_mode", "all")
to_delete: list[str] = []
if selection == "all":
props["all"] = 1
to_delete += ["vmid", "pool"]
elif selection == "pool" and data.get("pool"):
props["pool"] = data["pool"]
to_delete += ["vmid", "all"]
elif selection == "vmid":
vmid = data.get("vmid")
if isinstance(vmid, list):
vmid = ",".join(str(v) for v in vmid)
if vmid:
props["vmid"] = vmid
to_delete += ["all", "pool"]
if not for_create and to_delete:
props["delete"] = ",".join(to_delete)
# Retention → prune-backups="keep-last=N,keep-daily=N,..."
keep_parts = []
for key, pve_key in (
("keep_last", "keep-last"), ("keep_daily", "keep-daily"),
("keep_weekly", "keep-weekly"), ("keep_monthly", "keep-monthly"),
):
val = data.get(key)
if val:
keep_parts.append(f"{pve_key}={int(val)}")
if keep_parts:
props["prune-backups"] = ",".join(keep_parts)
return props
@router.post("/api/backups/proxmox-jobs/{server_id}")
async def create_proxmox_native_job(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Создать нативное задание бэкапа Proxmox (cluster/backup)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
if not data.get("schedule") or not data.get("storage"):
raise HTTPException(status_code=400, detail="schedule and storage are required")
props = _build_native_job_props(data, for_create=True)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_cluster_backup_job, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="create", resource_type="backup_job",
resource_id=str(props.get("id") or "native"), username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True, "schedule": props.get("schedule")},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/backups/proxmox-jobs/{server_id}/{job_id}")
async def update_proxmox_native_job(
server_id: int,
job_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Обновить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
data = await request.json()
props = _build_native_job_props(data, for_create=False)
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.update_cluster_backup_job, job_id, props)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="update", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/backups/proxmox-jobs/{server_id}/{job_id}")
async def delete_proxmox_native_job(
server_id: int,
job_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Удалить нативное задание бэкапа Proxmox (cluster/backup/{id})."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.delete_cluster_backup_job, job_id)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="delete", resource_type="backup_job",
resource_id=job_id, username=current_user.username,
server_id=server_id, server_name=server.name, success=True,
details={"native": True},
)
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/backups/jobs")
def list_backup_jobs(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List scheduled backup jobs"""
if current_user.is_admin or (current_user.role and current_user.role.name == "admin"):
jobs = db.query(BackupJob).all()
else:
jobs = db.query(BackupJob).filter(BackupJob.owner_id == current_user.id).all()
return JSONResponse(content={"jobs": [j.to_dict() for j in jobs]})
@router.post("/api/backups/jobs")
async def create_backup_job(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Create a new scheduled backup job"""
data = await request.json()
required = ["server_id", "node", "vmids", "storage", "cron_expression"]
missing = [f for f in required if not data.get(f)]
if missing:
raise HTTPException(status_code=400, detail=f"Missing fields: {', '.join(missing)}")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == data["server_id"]).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
job = BackupJob(
server_id=data["server_id"],
node=data["node"],
vmids=data["vmids"],
storage=data["storage"],
mode=data.get("mode", "snapshot"),
compress=data.get("compress", "zstd"),
notes=data.get("notes"),
keep_last=int(data.get("keep_last", 3)),
keep_daily=int(data.get("keep_daily", 7)),
keep_weekly=int(data.get("keep_weekly", 4)),
keep_monthly=int(data.get("keep_monthly", 6)),
cron_expression=data["cron_expression"],
enabled=bool(data.get("enabled", True)),
owner_id=current_user.id,
)
db.add(job)
db.commit()
db.refresh(job)
# Register with scheduler
try:
from ...services.backup_scheduler import scheduler
scheduler.add_job_from_model(job)
except Exception as e:
logger.warning(f"Could not register job {job.id} with scheduler: {e}")
LoggingService.log_proxmox_action(
db=db, action="create", resource_type="backup_job",
resource_id=str(job.id), username=current_user.username, success=True,
server_id=job.server_id, node_name=job.node,
)
return JSONResponse(content={"success": True, "job": job.to_dict()})
@router.put("/api/backups/jobs/{job_id}")
async def update_backup_job(
job_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Update a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not (current_user.is_admin or
(current_user.role and current_user.role.name == "admin") or
job.owner_id == current_user.id):
raise HTTPException(status_code=403, detail="Access denied")
data = await request.json()
updatable = ["node", "vmids", "storage", "mode", "compress", "notes",
"keep_last", "keep_daily", "keep_weekly", "keep_monthly",
"cron_expression", "enabled"]
for field in updatable:
if field in data:
setattr(job, field, data[field])
job.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
try:
from ...services.backup_scheduler import scheduler
scheduler.update_job_from_model(job)
except Exception as e:
logger.warning(f"Could not update job {job.id} in scheduler: {e}")
return JSONResponse(content={"success": True, "job": job.to_dict()})
@router.delete("/api/backups/jobs/{job_id}")
def delete_backup_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Delete a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if not (current_user.is_admin or
(current_user.role and current_user.role.name == "admin") or
job.owner_id == current_user.id):
raise HTTPException(status_code=403, detail="Access denied")
try:
from ...services.backup_scheduler import scheduler
scheduler.remove_job(job_id)
except Exception as e:
logger.warning(f"Could not remove job {job_id} from scheduler: {e}")
db.delete(job)
db.commit()
return JSONResponse(content={"success": True})
@router.patch("/api/backups/jobs/{job_id}/toggle")
def toggle_backup_job(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Enable or disable a scheduled backup job"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.enabled = not job.enabled
job.updated_at = datetime.now(timezone.utc)
db.commit()
try:
from ...services.backup_scheduler import scheduler
if job.enabled:
scheduler.add_job_from_model(job)
else:
scheduler.remove_job(job_id)
except Exception as e:
logger.warning(f"Could not toggle job {job_id} in scheduler: {e}")
return JSONResponse(content={"success": True, "enabled": job.enabled})
@router.post("/api/backups/jobs/{job_id}/run-now")
def run_backup_job_now(
job_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:create")),
):
"""Trigger a scheduled backup job immediately"""
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
try:
from ...services.backup_scheduler import run_backup_job
import asyncio
# Run in background - fire and forget via thread
import threading
thread = threading.Thread(
target=lambda: asyncio.run(run_backup_job(job_id)),
daemon=True,
)
thread.start()
return JSONResponse(content={"success": True, "message": "Job triggered"})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))