feat: SSH keys management, backups fixes, instances/nodes/templates improvements, dev mode support
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
markmorado
2026-05-07 00:30:21 +02:00
parent a92cb0df33
commit 323d1e57fa
40 changed files with 4112 additions and 288 deletions

View File

@@ -137,20 +137,59 @@ def delete_storage(
@router.get("/api/backups/list/{server_id}")
def list_backups(
server_id: int,
node: str,
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 on a node"""
"""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)
backups = client.list_backups(node, storage, vmid)
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))

View File

@@ -53,6 +53,8 @@ class LXCDeployRequest(BaseModel):
ipam_pool_id: Optional[int] = None
password: Optional[str] = None
ssh_keys: Optional[str] = None
ssh_key_ids: Optional[List[int]] = None
owner_id: Optional[int] = None
nameserver: Optional[str] = None
searchdomain: Optional[str] = None
unprivileged: bool = True
@@ -105,7 +107,8 @@ def _update_task(task_id: int, status: str, step: str, progress: int,
db.close()
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str):
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str,
owner_id: Optional[int] = None):
"""Blocking LXC deploy — runs in thread pool."""
db = SessionLocal()
server = None
@@ -286,7 +289,7 @@ def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username:
template_id=None,
template_name=req.ostemplate,
description=f"Created from CT template {req.ostemplate}",
owner_id=user_id,
owner_id=owner_id or user_id,
)
LoggingService.log_proxmox_action(
@@ -434,6 +437,23 @@ async def deploy_lxc_from_template(
if not req.name or len(req.name) < 1:
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
# Resolve owner / SSH keys
is_admin = current_user.has_permission("users.manage") or current_user.is_admin
instance_owner_id = current_user.id
if req.owner_id and req.owner_id != current_user.id:
if not is_admin:
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
owner = db.query(User).filter(User.id == req.owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
if req.ssh_key_ids:
from ..ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)
existing = (req.ssh_keys or "").strip()
req.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
# Create task record
task = DeployTask(
status="pending",
@@ -456,6 +476,7 @@ async def deploy_lxc_from_template(
req,
current_user.id,
current_user.username,
instance_owner_id,
)
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)

View File

@@ -523,6 +523,37 @@ def delete_proxmox_server(
return None
@router.post("/api/servers/test", status_code=status.HTTP_200_OK)
def test_proxmox_credentials(
payload: ProxmoxServerCreate,
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
try:
if payload.use_password:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
password=payload.password,
verify_ssl=payload.verify_ssl,
)
else:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
token_name=payload.api_token_name,
token_value=payload.api_token_value,
verify_ssl=payload.verify_ssl,
)
if client.is_connected():
return {"success": True, "status": "success", "message": "Connection successful"}
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
except Exception as e:
logger.error(f"Error testing Proxmox credentials for {payload.ip_address}: {e}")
return {"success": False, "status": "error", "message": str(e)}
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
def test_proxmox_connection(
server_id: int,
@@ -533,25 +564,25 @@ def test_proxmox_connection(
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
# Determine auth method
client = _get_proxmox_client(server)
if client.is_connected():
server.update_status(True)
db.commit()
return {"status": "success", "message": "Connection successful"}
return {"success": True, "status": "success", "message": "Connection successful"}
else:
server.update_status(False, "Failed to connect")
db.commit()
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
except Exception as e:
server.update_status(False, str(e))
db.commit()
logger.error(f"Error testing Proxmox connection to {server.name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"success": False, "status": "error", "message": str(e)}
# ==================== Proxmox Resources ====================

View File

@@ -152,7 +152,24 @@ def get_all_virtual_machines(
query = query.filter(VMInstance.owner_id == current_user.id)
cached_vms = query.order_by(VMInstance.name).all()
# ── Live metrics from Proxmox cluster/resources (CPU, RAM, disk, uptime) ──
# One HTTP call per server returns metrics for all VMs/LXC in that cluster.
# Map: (server_id_in_db, vmid) -> live data dict
live_metrics: dict[tuple[int, int], dict] = {}
for server in servers:
try:
client = _get_proxmox_client(server)
if not client.is_connected():
continue
for res in client.get_cluster_resources(type_='vm'):
vmid = res.get('vmid')
if vmid is None:
continue
live_metrics[(server.id, vmid)] = res
except Exception as e:
logger.debug(f"Could not fetch live metrics from {server.name}: {e}")
result = []
# Detect cluster servers (servers with hostnames pve1, pve2, pve3 pattern)
@@ -195,7 +212,10 @@ def get_all_virtual_machines(
os_template = vm.template_name or vm.os_type or ("QEMU/KVM" if vm.vm_type == "qemu" else "Linux")
if not vm.template_name and vm.vm_type == "lxc" and os_template:
os_template = os_template.capitalize()
# Live metrics (cpu/mem/disk/uptime/net) from cluster/resources
live = live_metrics.get((vm.server_id, vm.vmid)) or {}
result.append({
"server_id": vm.server_id,
"server_name": server_name,
@@ -204,18 +224,28 @@ def get_all_virtual_machines(
"name": vm.name,
"hostname": ip_hostname or f"{'vps' if vm.vm_type == 'qemu' else 'lxc'}{vm.vmid}.{server.hostname if server else 'local'}",
"type": vm.vm_type,
"status": vm.status or "unknown",
"status": live.get('status') or vm.status or "unknown",
"node": vm.node,
"cores": vm.cores or 0,
"memory": vm.memory or 0,
"disk": vm.disk_size or 0,
"disk": live.get('disk') or 0,
"ip": ip_address,
"ip_address": ip_address,
"ip_hostname": ip_network_name,
"os": os_template,
"os_template": os_template,
"owner": owner,
"owner_hostname": "",
"storage": "Storage1 (DIR)"
"storage": "Storage1 (DIR)",
"tags": vm.tags or "",
# Live runtime metrics
"cpu": live.get('cpu'),
"mem": live.get('mem'),
"maxmem": live.get('maxmem') or vm.memory or 0,
"maxdisk": live.get('maxdisk') or vm.disk_size or 0,
"uptime": live.get('uptime'),
"netin": live.get('netin'),
"netout": live.get('netout'),
})
return JSONResponse(content=result)