refactor: split proxmox.py (4676 lines) into package with 7 modules

- _helpers.py: shared helpers (check_vm_access, _get_proxmox_client, etc.)
- servers.py: HTML pages, server CRUD, resources, node status (16 routes)
- sdn.py: SDN zones/vnets/subnets (10 routes)
- snapshots.py: VM/container snapshots + archives (10 routes)
- vms.py: VM/LXC control, HA, monitoring, VNC, terminal, bulk ops (42 routes)
- tasks.py: Proxmox task management (6 routes)
- __init__.py: re-exports unified router (84 routes total, no public API change)

https://claude.ai/code/session_019rK2wvaWg5zXFwmAPPEB7R
This commit is contained in:
Claude
2026-03-07 17:35:17 +00:00
parent 7bd2dd5b8f
commit 6fb7406d27
7 changed files with 2156 additions and 2013 deletions

View File

@@ -0,0 +1,416 @@
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
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_client import ProxmoxClient, get_proxmox_resources
from ..auth import get_current_user, PermissionChecker, require_permission, check_permission
from ..logging_service import LoggingService
from ..template_helpers import add_i18n_context
from ..ipam_service import IPAMService
templates = Jinja2Templates(directory="app/templates")
# ==================== Helper Functions for User Isolation ====================
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
"""
Check if user has access to a specific VM instance.
Admins and users with 'vms:view' permission can access all VMs.
Users with 'user' role can only access VMs where they are the owner.
Returns True if access is allowed, False otherwise.
"""
# Admin role has full access
if current_user.role and current_user.role.name == 'admin':
return True
# Check permissions
if current_user.role and current_user.role.permissions:
perms = current_user.role.permissions
# If user has full vms:view, they can access all VMs
has_full_view = perms.get('vms.view', False) or perms.get('vms:view', False)
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
if has_full_view and not has_own_only:
return True
# For limited users, check ownership
if current_user.role and current_user.role.name == 'user':
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if instance and instance.owner_id == current_user.id:
return True
return False
# Default: allow access (for backwards compatibility)
return True
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
"""
Require user to have access to VM, raise 403 if not.
"""
if not check_vm_access(db, current_user, server_id, vmid):
raise HTTPException(
status_code=403,
detail="You do not have access to this virtual machine"
)
def _get_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth)."""
if server.use_password:
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
def get_next_vmid(db: Session, server_id: int) -> int:
"""Get next available VMID from Proxmox server (sequential)"""
# Get server from DB
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
# Create Proxmox client and get next VMID from Proxmox API
client = _get_proxmox_client(server)
# Get next VMID from Proxmox (it returns sequential IDs)
vmid = client.get_next_vmid()
if vmid:
return vmid
except Exception as e:
logger.warning(f"Could not get VMID from Proxmox, falling back to DB: {e}")
# Fallback: get from DB if Proxmox not available
used_vmids = set(
row[0] for row in db.query(VMInstance.vmid)
.filter(VMInstance.server_id == server_id, VMInstance.deleted_at == None)
.all()
)
# Find first available VMID starting from 100
for vmid in range(100, 999999):
if vmid not in used_vmids:
return vmid
raise HTTPException(status_code=500, detail="No available VMID")
def archive_and_delete_snapshots(
db: Session,
client: ProxmoxClient,
server_id: int,
server_name: str,
vmid: int,
vm_name: str,
vm_type: str,
node: str,
deleted_by: str,
deletion_reason: str = None
) -> dict:
"""
Archive all snapshots to database before deleting them.
This function:
1. Gets list of all snapshots for the VM/container
2. Saves each snapshot's configuration to vm_snapshot_archives table
3. Explicitly deletes each snapshot one by one
4. Logs each deletion
Args:
db: Database session
client: ProxmoxClient instance
server_id: Proxmox server ID
server_name: Proxmox server name for logging
vmid: VM/container ID
vm_name: VM/container name
vm_type: 'qemu' for VM, 'lxc' for container
node: Proxmox node name
deleted_by: Username who initiated deletion
deletion_reason: Reason for deletion
Returns:
Dict with archived count, deleted count, and any errors
"""
result = {
"archived": 0,
"deleted": 0,
"errors": [],
"snapshots": []
}
try:
# Get all snapshots
if vm_type == 'lxc':
snapshots = client.get_container_snapshots(node, vmid)
else:
snapshots = client.get_vm_snapshots(node, vmid)
# Filter out 'current' state which is not a real snapshot
snapshots = [s for s in snapshots if s.get('name') != 'current']
if not snapshots:
logger.info(f"No snapshots found for {vm_type} {vmid}")
return result
logger.info(f"Found {len(snapshots)} snapshots to archive for {vm_type} {vmid}")
# Archive and delete each snapshot
for snap in snapshots:
snapname = snap.get('name')
if not snapname:
continue
# Create archive record
try:
archive = VMSnapshotArchive(
server_id=server_id,
server_name=server_name,
vmid=vmid,
vm_name=vm_name,
vm_type=vm_type,
node=node,
snapname=snapname,
description=snap.get('description'),
snaptime=snap.get('snaptime'),
parent=snap.get('parent'),
vmstate=bool(snap.get('vmstate')),
snapshot_config=snap,
deleted_by=deleted_by,
deletion_reason=deletion_reason
)
db.add(archive)
db.flush() # Get ID immediately
result["archived"] += 1
result["snapshots"].append(snapname)
logger.info(f"Archived snapshot {snapname} for {vm_type} {vmid}")
except Exception as e:
logger.error(f"Error archiving snapshot {snapname}: {e}")
result["errors"].append(f"Archive {snapname}: {str(e)}")
# Delete snapshot from Proxmox
try:
if vm_type == 'lxc':
delete_result = client.delete_container_snapshot(node, vmid, snapname, force=True)
else:
delete_result = client.delete_vm_snapshot(node, vmid, snapname, force=True)
if delete_result.get('success'):
result["deleted"] += 1
# Log the deletion
LoggingService.log_proxmox_action(
db=db,
action="snapshot_delete",
resource_type=vm_type,
resource_id=vmid,
username=deleted_by,
resource_name=snapname,
server_id=server_id,
server_name=server_name,
node_name=node,
details={
"reason": f"Pre-deletion cleanup for {vm_type} {vmid}",
"cascade_delete": True
},
success=True
)
logger.info(f"Deleted snapshot {snapname} for {vm_type} {vmid}")
else:
error_msg = delete_result.get('error', 'Unknown error')
result["errors"].append(f"Delete {snapname}: {error_msg}")
logger.warning(f"Failed to delete snapshot {snapname}: {error_msg}")
except Exception as e:
logger.error(f"Error deleting snapshot {snapname}: {e}")
result["errors"].append(f"Delete {snapname}: {str(e)}")
# Commit archive records
db.commit()
logger.info(f"Snapshot cleanup for {vm_type} {vmid}: archived={result['archived']}, deleted={result['deleted']}")
except Exception as e:
logger.error(f"Error in archive_and_delete_snapshots for {vm_type} {vmid}: {e}")
result["errors"].append(f"General error: {str(e)}")
return result
def save_vm_instance(
db: Session,
server_id: int,
vmid: int,
node: str,
vm_type: str,
name: str,
cores: int = None,
memory: int = None,
disk_size: int = None,
ip_address: str = None,
ip_prefix: int = 24,
gateway: str = None,
nameserver: str = None,
cloud_init_user: str = None,
cloud_init_password: str = None,
ssh_keys: str = None,
template_id: int = None,
template_name: str = None,
description: str = None,
extra_config: dict = None,
owner_id: int = None
) -> VMInstance:
"""Save or update VM instance configuration"""
# Проверяем, существует ли уже активная запись
existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
).first()
if existing:
# Обновляем существующую запись
existing.node = node
existing.vm_type = vm_type
existing.name = name
existing.cores = cores
existing.memory = memory
existing.disk_size = disk_size
existing.ip_address = ip_address
existing.ip_prefix = ip_prefix
existing.gateway = gateway
existing.nameserver = nameserver
existing.cloud_init_user = cloud_init_user
existing.cloud_init_password = cloud_init_password
existing.ssh_keys = ssh_keys
existing.template_id = template_id
existing.template_name = template_name
existing.description = description
existing.extra_config = extra_config
existing.updated_at = func.now()
db.commit()
db.refresh(existing)
return existing
# Проверяем, есть ли удалённая запись с тем же vmid (soft-deleted)
deleted_existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at != None
).first()
if deleted_existing:
# Восстанавливаем и обновляем удалённую запись
deleted_existing.node = node
deleted_existing.vm_type = vm_type
deleted_existing.name = name
deleted_existing.cores = cores
deleted_existing.memory = memory
deleted_existing.disk_size = disk_size
deleted_existing.ip_address = ip_address
deleted_existing.ip_prefix = ip_prefix
deleted_existing.gateway = gateway
deleted_existing.nameserver = nameserver
deleted_existing.cloud_init_user = cloud_init_user
deleted_existing.cloud_init_password = cloud_init_password
deleted_existing.ssh_keys = ssh_keys
deleted_existing.template_id = template_id
deleted_existing.template_name = template_name
deleted_existing.description = description
deleted_existing.extra_config = extra_config
deleted_existing.owner_id = owner_id
deleted_existing.status = 'unknown'
deleted_existing.deleted_at = None # Восстанавливаем запись
deleted_existing.updated_at = func.now()
db.commit()
db.refresh(deleted_existing)
return deleted_existing
# Создаем новую запись
instance = VMInstance(
server_id=server_id,
vmid=vmid,
node=node,
vm_type=vm_type,
name=name,
cores=cores,
memory=memory,
disk_size=disk_size,
ip_address=ip_address,
ip_prefix=ip_prefix,
gateway=gateway,
nameserver=nameserver,
cloud_init_user=cloud_init_user,
cloud_init_password=cloud_init_password,
ssh_keys=ssh_keys,
template_id=template_id,
template_name=template_name,
description=description,
extra_config=extra_config,
owner_id=owner_id
)
db.add(instance)
db.commit()
db.refresh(instance)
return instance
def get_vm_instance(db: Session, server_id: int, vmid: int) -> VMInstance:
"""Get VM instance configuration"""
return db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
).first()
def soft_delete_vm_instance(db: Session, server_id: int, vmid: int):
"""Soft delete VM instance (mark as deleted) and release IPAM"""
instance = get_vm_instance(db, server_id, vmid)
if instance:
# Release IPAM allocation
try:
ipam = IPAMService(db)
released = ipam.release_ip_by_vmid(
proxmox_server_id=server_id,
proxmox_vmid=vmid,
released_by="system",
reason="VM/Container soft deleted"
)
if released:
logger.info(f"Released IPAM for soft-deleted instance {vmid} on server {server_id}")
except Exception as e:
logger.warning(f"Failed to release IPAM for soft-deleted instance: {e}")
instance.deleted_at = func.now()
db.commit()