feat: LXC container creation from CT templates

- Add LXC deploy API endpoints: GET /proxmox/api/lxc-templates/{id},
  GET /proxmox/api/lxc-storages/{id}, POST /proxmox/api/lxc/deploy
- Add ProxmoxClient.get_lxc_storage_templates() and get_node_storages()
- Fix duplicate create_lxc_container() — removed dead copy, updated
  canonical with nameserver/searchdomain params
- Fix wait_for_task(): accept WARNINGS:N exitstatus as success
- Add IPAM IP release on deploy failure
- Add frontend hooks: useLXCTemplates, useLXCStorages, useDeployLXC
  with 5-min staleTime caching
- Add LXC wizard steps to CreateInstanceWizard (type, template, config)
- Add LXC i18n keys to en.json and ru.json
- Add LXCTemplate and LXCDeployRequest TypeScript interfaces
This commit is contained in:
markmorado
2026-05-06 19:17:04 +02:00
parent adcc86f30a
commit 1cf099ba1d
8 changed files with 1000 additions and 39 deletions

View File

@@ -7,6 +7,7 @@ from .tasks import router as tasks_router
from .backup import router as backup_router
from .cluster import router as cluster_router
from .networks import router as networks_router
from .lxc import router as lxc_router
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
router = APIRouter()
@@ -18,3 +19,4 @@ router.include_router(tasks_router)
router.include_router(backup_router)
router.include_router(cluster_router)
router.include_router(networks_router)
router.include_router(lxc_router)

View File

@@ -0,0 +1,461 @@
"""
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_client 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
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):
"""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=None,
cloud_init_password=None,
ssh_keys=req.ssh_keys,
template_id=None,
template_name=req.ostemplate,
description=f"Created from CT template {req.ostemplate}",
owner_id=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("vms.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("vms.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("vms.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="Укажите имя контейнера")
# 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,
)
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)