Files
pvemanager/backend/app/api/proxmox/snapshots.py
Dilshod Rasulov de967c35e0 refactor(rbac): migrate API permission checks to resource:action format
Replace all legacy dotted permission codes (vms.view, proxmox.manage, ...)
in PermissionChecker/has_permission across app/api with the new
resource:action format (vm:view, server:manage, ...), per LEGACY_PERMISSION_MAP.
All 33 codes used by the API are now registered in the permission registry.

Also switch the /api/auth/me admin branch to get_all_permissions_v2 so the
endpoint emits permissions in the new format only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:07:48 +05:00

460 lines
16 KiB
Python

from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
import ssl
import asyncio
import httpx
import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
router = APIRouter()
# ==================== Snapshots ====================
@router.get("/api/{server_id}/vm/{vmid}/snapshots")
def get_vm_snapshots(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Get all snapshots for a VM"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
snapshots = client.get_vm_snapshots(node, vmid)
# Filter out 'current' pseudo-snapshot if present
snapshots = [s for s in snapshots if s.get('name') != 'current']
return JSONResponse(content={"snapshots": snapshots})
except Exception as e:
logger.error(f"Error getting VM {vmid} snapshots: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/snapshots")
async def create_vm_snapshot(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Create a VM snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
snapname = data.get('snapname')
description = data.get('description')
vmstate = data.get('vmstate', False)
if not snapname:
raise HTTPException(status_code=400, detail="Snapshot name is required")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_vm_snapshot, node, vmid, snapname, description, vmstate)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_create",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} created snapshot {snapname} for VM {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating VM {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/{server_id}/vm/{vmid}/snapshots/{snapname}")
def delete_vm_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Delete a VM snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_vm_snapshot(node, vmid, snapname)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} deleted snapshot {snapname} for VM {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting VM {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/snapshots/{snapname}/rollback")
def rollback_vm_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
start: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Rollback a VM to a snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.rollback_vm_snapshot(node, vmid, snapname, start)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_rollback",
resource_type="vm",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} rolled back VM {vmid} to snapshot {snapname}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to rollback snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rolling back VM {vmid} to snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Container Snapshots
@router.get("/api/{server_id}/container/{vmid}/snapshots")
def get_container_snapshots(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Get all snapshots for a container"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
snapshots = client.get_container_snapshots(node, vmid)
# Filter out 'current' pseudo-snapshot if present
snapshots = [s for s in snapshots if s.get('name') != 'current']
return JSONResponse(content={"snapshots": snapshots})
except Exception as e:
logger.error(f"Error getting container {vmid} snapshots: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/snapshots")
async def create_container_snapshot(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Create a container snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
snapname = data.get('snapname')
description = data.get('description')
if not snapname:
raise HTTPException(status_code=400, detail="Snapshot name is required")
try:
client = _get_proxmox_client(server)
result = await _run_in_executor(client.create_container_snapshot, node, vmid, snapname, description)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_create",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} created snapshot {snapname} for container {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating container {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/{server_id}/container/{vmid}/snapshots/{snapname}")
def delete_container_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Delete a container snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.delete_container_snapshot(node, vmid, snapname)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} deleted snapshot {snapname} for container {vmid}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting container {vmid} snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/snapshots/{snapname}/rollback")
def rollback_container_snapshot(
server_id: int,
vmid: int,
snapname: str,
node: str,
start: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""Rollback a container to a snapshot"""
# VPS-style user isolation
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
result = client.rollback_container_snapshot(node, vmid, snapname, start)
if result.get('success'):
LoggingService.log_proxmox_action(
db=db,
action="snapshot_rollback",
resource_type="container",
resource_id=vmid,
username=current_user.username,
resource_name=snapname,
server_id=server_id,
server_name=server.name,
node_name=node,
success=True
)
logger.info(f"User {current_user.username} rolled back container {vmid} to snapshot {snapname}")
return JSONResponse(content=result)
else:
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to rollback snapshot'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rolling back container {vmid} to snapshot: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Snapshot Archives ====================
@router.get("/api/snapshot-archives")
def get_snapshot_archives(
server_id: int = None,
vmid: int = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("log:view"))
):
"""
Get archived snapshots from deleted VMs/containers.
Admin-only endpoint for audit and recovery purposes.
"""
query = db.query(VMSnapshotArchive).order_by(VMSnapshotArchive.archived_at.desc())
if server_id:
query = query.filter(VMSnapshotArchive.server_id == server_id)
if vmid:
query = query.filter(VMSnapshotArchive.vmid == vmid)
total = query.count()
archives = query.offset(offset).limit(limit).all()
return JSONResponse(content={
"total": total,
"offset": offset,
"limit": limit,
"archives": [
{
"id": a.id,
"server_id": a.server_id,
"server_name": a.server_name,
"vmid": a.vmid,
"vm_name": a.vm_name,
"vm_type": a.vm_type,
"node": a.node,
"snapname": a.snapname,
"description": a.description,
"snaptime": a.snaptime,
"parent": a.parent,
"vmstate": a.vmstate,
"deleted_by": a.deleted_by,
"deletion_reason": a.deletion_reason,
"archived_at": a.archived_at.isoformat() if a.archived_at else None
}
for a in archives
]
})
@router.get("/api/snapshot-archives/{archive_id}")
def get_snapshot_archive_detail(
archive_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("log:view"))
):
"""Get full details of an archived snapshot including config"""
archive = db.query(VMSnapshotArchive).filter(VMSnapshotArchive.id == archive_id).first()
if not archive:
raise HTTPException(status_code=404, detail="Archive not found")
return JSONResponse(content={
"id": archive.id,
"server_id": archive.server_id,
"server_name": archive.server_name,
"vmid": archive.vmid,
"vm_name": archive.vm_name,
"vm_type": archive.vm_type,
"node": archive.node,
"snapname": archive.snapname,
"description": archive.description,
"snaptime": archive.snaptime,
"parent": archive.parent,
"vmstate": archive.vmstate,
"snapshot_config": archive.snapshot_config,
"deleted_by": archive.deleted_by,
"deletion_reason": archive.deletion_reason,
"archived_at": archive.archived_at.isoformat() if archive.archived_at else None
})