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>
483 lines
18 KiB
Python
483 lines
18 KiB
Python
"""
|
||
API endpoints for creating LXC containers from Proxmox CT templates (vztmpl files).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime, timezone
|
||
from typing import List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.responses import JSONResponse
|
||
from loguru import logger
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ...auth import PermissionChecker
|
||
from ...db import get_db, SessionLocal
|
||
from ...ipam_service import IPAMService
|
||
from ...logging_service import LoggingService
|
||
from ...models import DeployTask, IPAMNetwork, ProxmoxServer, User, VMInstance
|
||
from ...proxmox import ProxmoxClient
|
||
from ...websocket_manager import broadcast_task_update
|
||
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance
|
||
|
||
router = APIRouter()
|
||
|
||
_lxc_deploy_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="lxc_deploy")
|
||
|
||
|
||
def _utcnow() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||
|
||
class LXCDeployRequest(BaseModel):
|
||
server_id: int
|
||
ostemplate: str # volid, e.g. "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
|
||
node: Optional[str] = None
|
||
name: str # hostname / display name
|
||
vmid: Optional[int] = None
|
||
cores: int = 1
|
||
memory: int = 512 # МБ
|
||
swap: int = 512 # МБ
|
||
disk: int = 8 # ГБ
|
||
storage: str = "local-lvm"
|
||
bridge: str = "vmbr0"
|
||
ip_address: Optional[str] = None
|
||
gateway: Optional[str] = None
|
||
ipam_network_id: Optional[int] = None
|
||
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
|
||
start_after_create: bool = True
|
||
onboot: bool = False
|
||
|
||
|
||
class LXCDeployResponse(BaseModel):
|
||
task_id: int
|
||
status: str
|
||
name: str
|
||
|
||
|
||
class LXCTemplateItem(BaseModel):
|
||
volid: str
|
||
storage: str
|
||
node: str
|
||
name: str
|
||
size: int
|
||
|
||
|
||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def _update_task(task_id: int, status: str, step: str, progress: int,
|
||
vmid: int = None, node: str = None, error: str = None):
|
||
db = SessionLocal()
|
||
try:
|
||
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
|
||
if not task:
|
||
return
|
||
task.status = status
|
||
task.step = step
|
||
task.progress = progress
|
||
if vmid is not None:
|
||
task.vmid = vmid
|
||
if node is not None:
|
||
task.node = node
|
||
if error is not None:
|
||
task.error_message = error
|
||
if status == "running" and not task.started_at:
|
||
task.started_at = _utcnow()
|
||
if status in ("completed", "failed"):
|
||
task.completed_at = _utcnow()
|
||
db.commit()
|
||
try:
|
||
broadcast_task_update(task.user_id, "deploy_task_update", task.to_dict())
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
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
|
||
deploy_node = None
|
||
new_vmid = None
|
||
ipam_allocation = None
|
||
try:
|
||
_update_task(task_id, "running", "Подготовка...", 5)
|
||
|
||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
|
||
if not server:
|
||
_update_task(task_id, "failed", "Сервер не найден", 0, error="Сервер Proxmox не найден")
|
||
return
|
||
|
||
# Connect
|
||
_update_task(task_id, "running", "Подключение к Proxmox...", 10)
|
||
client = _get_proxmox_client(server)
|
||
if not client.is_connected():
|
||
_update_task(task_id, "failed", "Ошибка подключения к Proxmox", 10,
|
||
error="Не удалось подключиться к Proxmox серверу")
|
||
return
|
||
|
||
# Determine node
|
||
deploy_node = req.node
|
||
if not deploy_node:
|
||
nodes = client.get_nodes()
|
||
if not nodes:
|
||
_update_task(task_id, "failed", "Нет доступных нод", 15, error="Не найдено нод Proxmox")
|
||
return
|
||
# Try to find a node that has the template storage
|
||
for n in nodes:
|
||
try:
|
||
storages = client.proxmox.nodes(n["node"]).storage.get()
|
||
for s in storages:
|
||
if "vztmpl" in s.get("content", ""):
|
||
deploy_node = n["node"]
|
||
break
|
||
except Exception:
|
||
pass
|
||
if deploy_node:
|
||
break
|
||
if not deploy_node:
|
||
deploy_node = nodes[0]["node"]
|
||
|
||
# Allocate VMID
|
||
_update_task(task_id, "running", "Генерация VMID...", 20)
|
||
if req.vmid:
|
||
new_vmid = req.vmid
|
||
else:
|
||
new_vmid = get_next_vmid(db, server.id)
|
||
|
||
# IPAM
|
||
_update_task(task_id, "running", "Выделение IP-адреса...", 25)
|
||
allocated_ip = req.ip_address
|
||
gateway = req.gateway
|
||
network_mask = "24"
|
||
ipam_allocation = None
|
||
|
||
if req.ipam_network_id and not req.ip_address:
|
||
ipam = IPAMService(db)
|
||
ipam_allocation, error = ipam.auto_allocate_ip(
|
||
network_id=req.ipam_network_id,
|
||
pool_id=req.ipam_pool_id,
|
||
resource_type="ct",
|
||
resource_name=req.name,
|
||
proxmox_server_id=server.id,
|
||
proxmox_vmid=new_vmid,
|
||
proxmox_node=deploy_node,
|
||
allocated_by=username,
|
||
notes=f"Auto-allocated for LXC {req.name}",
|
||
)
|
||
if not error:
|
||
allocated_ip = ipam_allocation.ip_address
|
||
if not gateway:
|
||
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == req.ipam_network_id).first()
|
||
if net and net.gateway:
|
||
gateway = net.gateway
|
||
elif req.ip_address and req.ipam_network_id:
|
||
ipam = IPAMService(db)
|
||
ipam_allocation, _ = ipam.allocate_ip(
|
||
ip_address=req.ip_address,
|
||
network_id=req.ipam_network_id,
|
||
pool_id=req.ipam_pool_id,
|
||
resource_type="ct",
|
||
resource_name=req.name,
|
||
proxmox_server_id=server.id,
|
||
proxmox_vmid=new_vmid,
|
||
proxmox_node=deploy_node,
|
||
allocated_by=username,
|
||
notes=f"Manually assigned for LXC {req.name}",
|
||
)
|
||
|
||
if allocated_ip and req.ipam_network_id:
|
||
try:
|
||
network_id = req.ipam_network_id
|
||
if ipam_allocation and hasattr(ipam_allocation, "network_id"):
|
||
network_id = ipam_allocation.network_id
|
||
if network_id:
|
||
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||
if net and net.network and "/" in net.network:
|
||
network_mask = net.network.split("/")[1]
|
||
if not gateway and net and net.gateway:
|
||
gateway = net.gateway
|
||
except Exception as e:
|
||
logger.warning(f"Could not get network info from IPAM: {e}")
|
||
|
||
# Build ip_config string for LXC net0
|
||
if allocated_ip:
|
||
ip_config = f"ip={allocated_ip}/{network_mask},gw={gateway}" if gateway else f"ip={allocated_ip}/{network_mask}"
|
||
else:
|
||
ip_config = "ip=dhcp"
|
||
|
||
# Create LXC
|
||
_update_task(task_id, "running", f"Создание LXC контейнера VMID {new_vmid}...", 35,
|
||
vmid=new_vmid, node=deploy_node)
|
||
net0_str = f"name=eth0,bridge={req.bridge},{ip_config},firewall=1"
|
||
upid = client.create_lxc_container(
|
||
node=deploy_node,
|
||
vmid=new_vmid,
|
||
ostemplate=req.ostemplate,
|
||
hostname=req.name,
|
||
cores=req.cores,
|
||
memory=req.memory,
|
||
swap=req.swap,
|
||
storage=req.storage,
|
||
rootfs_size=req.disk,
|
||
net0=net0_str,
|
||
nameserver=req.nameserver,
|
||
searchdomain=req.searchdomain,
|
||
password=req.password,
|
||
ssh_public_keys=req.ssh_keys,
|
||
unprivileged=req.unprivileged,
|
||
start_after_create=False, # we start manually after config
|
||
onboot=req.onboot,
|
||
description=f"Created from {req.ostemplate} by {username}",
|
||
)
|
||
if not upid:
|
||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||
error="Proxmox не вернул UPID задачи создания контейнера")
|
||
return
|
||
|
||
# Wait for creation task
|
||
_update_task(task_id, "running", "Ожидание завершения создания...", 50,
|
||
vmid=new_vmid, node=deploy_node)
|
||
success = client.wait_for_task(deploy_node, upid, timeout=300)
|
||
if not success:
|
||
_update_task(task_id, "failed", "Таймаут создания контейнера", 50,
|
||
error="Создание контейнера заняло слишком долго (>5 мин)")
|
||
return
|
||
|
||
_update_task(task_id, "running", "Контейнер создан", 70, vmid=new_vmid, node=deploy_node)
|
||
|
||
# Start if requested
|
||
if req.start_after_create:
|
||
_update_task(task_id, "running", "Запуск контейнера...", 85,
|
||
vmid=new_vmid, node=deploy_node)
|
||
client.start_container(deploy_node, new_vmid)
|
||
|
||
# Save to DB
|
||
_update_task(task_id, "running", "Сохранение в базе данных...", 95,
|
||
vmid=new_vmid, node=deploy_node)
|
||
save_vm_instance(
|
||
db=db,
|
||
server_id=server.id,
|
||
vmid=new_vmid,
|
||
node=deploy_node,
|
||
vm_type="lxc",
|
||
name=req.name,
|
||
cores=req.cores,
|
||
memory=req.memory,
|
||
disk_size=req.disk,
|
||
ip_address=allocated_ip,
|
||
ip_prefix=int(network_mask),
|
||
gateway=gateway,
|
||
cloud_init_user="root",
|
||
cloud_init_password=req.password,
|
||
ssh_keys=req.ssh_keys,
|
||
template_id=None,
|
||
template_name=req.ostemplate,
|
||
description=f"Created from CT template {req.ostemplate}",
|
||
owner_id=owner_id or user_id,
|
||
)
|
||
|
||
LoggingService.log_proxmox_action(
|
||
db=db,
|
||
action="create",
|
||
resource_type="ct",
|
||
resource_id=new_vmid,
|
||
username=username,
|
||
resource_name=req.name,
|
||
server_id=server.id,
|
||
server_name=server.name,
|
||
node_name=deploy_node,
|
||
details={
|
||
"ostemplate": req.ostemplate,
|
||
"cores": req.cores,
|
||
"memory": req.memory,
|
||
"disk": req.disk,
|
||
"ip_address": allocated_ip,
|
||
},
|
||
success=True,
|
||
)
|
||
|
||
_update_task(task_id, "completed", "Контейнер успешно создан!", 100,
|
||
vmid=new_vmid, node=deploy_node)
|
||
logger.info(f"[LXC DEPLOY] Task #{task_id}: {req.name} (VMID {new_vmid}) deployed by {username}")
|
||
|
||
except Exception as e:
|
||
err = str(e)
|
||
logger.error(f"[LXC DEPLOY] Task #{task_id} failed: {err}")
|
||
# Release IPAM allocation on failure
|
||
try:
|
||
if ipam_allocation is not None:
|
||
ipam = IPAMService(db)
|
||
ipam.release_ip(ipam_allocation.id)
|
||
logger.info(f"[LXC DEPLOY] Task #{task_id}: Released IPAM IP {ipam_allocation.ip_address} on failure")
|
||
except Exception as ipam_err:
|
||
logger.warning(f"[LXC DEPLOY] Task #{task_id}: Could not release IPAM IP: {ipam_err}")
|
||
try:
|
||
if server:
|
||
LoggingService.log_proxmox_action(
|
||
db=db,
|
||
action="create",
|
||
resource_type="ct",
|
||
resource_id=req.vmid,
|
||
username=username,
|
||
resource_name=req.name,
|
||
server_id=server.id,
|
||
server_name=server.name,
|
||
node_name=deploy_node,
|
||
details={"ostemplate": req.ostemplate},
|
||
success=False,
|
||
error_message=err,
|
||
)
|
||
except Exception:
|
||
pass
|
||
_update_task(task_id, "failed", f"Ошибка: {err[:150]}", 0, error=err)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/api/lxc-templates/{server_id}")
|
||
def list_lxc_templates(
|
||
server_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||
):
|
||
"""
|
||
Список LXC CT-шаблонов (vztmpl) на хранилищах выбранного Proxmox сервера.
|
||
"""
|
||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||
|
||
client = _get_proxmox_client(server)
|
||
if not client.is_connected():
|
||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||
|
||
node_filter = None
|
||
if server.hostname and server.hostname != server.ip_address:
|
||
node_filter = server.hostname
|
||
|
||
templates = client.get_lxc_storage_templates(node=node_filter)
|
||
return JSONResponse(content={"server_id": server_id, "templates": templates})
|
||
|
||
|
||
@router.get("/api/lxc-storages/{server_id}")
|
||
def list_lxc_storages(
|
||
server_id: int,
|
||
node: Optional[str] = None,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||
):
|
||
"""
|
||
Список хранилищ ноды, пригодных для rootfs LXC контейнера.
|
||
"""
|
||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||
|
||
client = _get_proxmox_client(server)
|
||
if not client.is_connected():
|
||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||
|
||
target_node = node or server.hostname or None
|
||
if not target_node:
|
||
nodes = client.get_nodes()
|
||
target_node = nodes[0]["node"] if nodes else None
|
||
if not target_node:
|
||
raise HTTPException(status_code=400, detail="Не удалось определить ноду")
|
||
|
||
storages = client.get_node_storages(target_node)
|
||
result = []
|
||
for s in storages:
|
||
content = s.get("content", "")
|
||
if "rootdir" in content or "images" in content:
|
||
result.append({
|
||
"storage": s.get("storage"),
|
||
"type": s.get("type"),
|
||
"avail": s.get("avail"),
|
||
"total": s.get("total"),
|
||
"used": s.get("used"),
|
||
"active": s.get("active", 0),
|
||
})
|
||
return JSONResponse(content={"storages": result})
|
||
|
||
|
||
@router.post("/api/lxc/deploy", response_model=LXCDeployResponse, status_code=202)
|
||
async def deploy_lxc_from_template(
|
||
req: LXCDeployRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("vm:create")),
|
||
):
|
||
"""
|
||
Асинхронное создание LXC контейнера из CT-шаблона.
|
||
Возвращает task_id для отслеживания статуса.
|
||
"""
|
||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||
|
||
if not req.ostemplate:
|
||
raise HTTPException(status_code=400, detail="Необходимо указать ostemplate")
|
||
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("user: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",
|
||
step="В очереди...",
|
||
progress=0,
|
||
name=req.name,
|
||
user_id=current_user.id,
|
||
created_at=_utcnow(),
|
||
)
|
||
db.add(task)
|
||
db.commit()
|
||
db.refresh(task)
|
||
task_id = task.id
|
||
|
||
loop = asyncio.get_event_loop()
|
||
loop.run_in_executor(
|
||
_lxc_deploy_executor,
|
||
_do_lxc_deploy,
|
||
task_id,
|
||
req,
|
||
current_user.id,
|
||
current_user.username,
|
||
instance_owner_id,
|
||
)
|
||
|
||
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)
|