Files
pvemanager/backend/app/api/templates.py
Dilshod Rasulov 8cbba05dac
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m23s
Deploy to Production / Trigger production update (push) Failing after 10s
fix: validate instance name as DNS label and improve wizard UX
Add DNS name validation (RFC 952/1123) for VM, LXC and ISO instance
names on both backend and frontend. Proxmox rejects names with
underscores or special characters but the error was only surfaced as
a raw 400 from the API.

Also require Cloud-Init user and password fields for VM deploys
(highlighted when empty, Next button blocked), and fix numeric input
fields that showed "0" on clear and concatenated values like "04096".
2026-08-17 16:24:55 +05:00

1176 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
API endpoints for OS Templates management
Allows creating VM templates, groups, and deploying VMs from templates
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request, HTTPException, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from loguru import logger
from typing import List, Optional
from ..db import get_db, SessionLocal
from ..models import ProxmoxServer, OSTemplateGroup, OSTemplate, VMInstance, User, DeployTask
from ..schemas import (
OSTemplateGroupCreate, OSTemplateGroupUpdate, OSTemplateGroupResponse,
OSTemplateCreate, OSTemplateUpdate, OSTemplateResponse, OSTemplateWithGroup,
VMDeployRequest, VMDeployResponse
)
from ..proxmox import ProxmoxClient
from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
from ..auth import get_current_user, PermissionChecker
from ..ipam_service import IPAMService
from ..logging_service import LoggingService
from ..websocket_manager import broadcast_task_update
router = APIRouter()
# Thread pool for blocking deploy operations
_deploy_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="deploy")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── Deploy Task response schema ──────────────────────────────────────────────
class DeployTaskStartResponse(BaseModel):
task_id: int
status: str
name: str
class DeployTaskStatusResponse(BaseModel):
id: int
status: str
step: Optional[str]
progress: int
name: str
vmid: Optional[int]
node: Optional[str]
error_message: Optional[str]
created_at: Optional[str]
started_at: Optional[str]
completed_at: Optional[str]
kind: Optional[str] = 'deploy'
# ==================== Template Groups CRUD ====================
@router.get("/api/groups", response_model=List[OSTemplateGroupResponse])
def list_template_groups(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view"))
):
"""Получить список всех групп шаблонов"""
groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
return groups
@router.post("/api/groups", response_model=OSTemplateGroupResponse, status_code=status.HTTP_201_CREATED)
def create_template_group(
group_data: OSTemplateGroupCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Создать новую группу шаблонов"""
# Check if group with same name exists
existing = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == group_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Группа с именем '{group_data.name}' уже существует"
)
group = OSTemplateGroup(**group_data.model_dump())
db.add(group)
db.commit()
db.refresh(group)
logger.info(f"User {current_user.username} created template group: {group.name}")
return group
@router.get("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
def get_template_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view"))
):
"""Получить группу шаблонов по ID"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
return group
@router.put("/api/groups/{group_id}", response_model=OSTemplateGroupResponse)
def update_template_group(
group_id: int,
group_data: OSTemplateGroupUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Обновить группу шаблонов"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
update_data = group_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(group, field, value)
db.commit()
db.refresh(group)
logger.info(f"User {current_user.username} updated template group: {group.name}")
return group
@router.delete("/api/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Удалить группу шаблонов"""
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Check if there are templates in this group
templates_count = db.query(OSTemplate).filter(OSTemplate.group_id == group_id).count()
if templates_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно удалить группу: в ней {templates_count} шаблон(ов)"
)
logger.info(f"User {current_user.username} deleted template group: {group.name}")
db.delete(group)
db.commit()
return None
# ==================== OS Templates CRUD ====================
@router.get("/api/templates", response_model=List[OSTemplateWithGroup])
def list_os_templates(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view")),
group_id: int = None,
server_id: int = None,
vm_type: Optional[str] = None,
active_only: bool = True
):
"""Получить список всех OS шаблонов с информацией о группе"""
query = db.query(OSTemplate)
if group_id:
query = query.filter(OSTemplate.group_id == group_id)
if server_id:
query = query.filter(OSTemplate.server_id == server_id)
if vm_type in ('qemu', 'lxc'):
query = query.filter(OSTemplate.vm_type == vm_type)
if active_only:
query = query.filter(OSTemplate.is_active == True)
os_templates = query.order_by(OSTemplate.sort_order).all()
# Enrich with group and server info
result = []
for template in os_templates:
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
template_dict = {
**template.__dict__,
'group_name': group.name if group else None,
'group_icon': group.icon if group else None,
'server_name': server.name if server else None,
}
# Remove SQLAlchemy internal state
template_dict.pop('_sa_instance_state', None)
result.append(template_dict)
return result
@router.post("/api/templates", response_model=OSTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_os_template(
template_data: OSTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Создать новый OS шаблон"""
# Validate group exists
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template_data.group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Validate server exists
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template_data.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
# Check if template with same VMID on same server exists
existing = db.query(OSTemplate).filter(
OSTemplate.server_id == template_data.server_id,
OSTemplate.vmid == template_data.vmid
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Шаблон с VMID {template_data.vmid} уже существует для этого сервера"
)
template = OSTemplate(**template_data.model_dump())
db.add(template)
db.commit()
db.refresh(template)
logger.info(f"User {current_user.username} created OS template: {template.name} (VMID: {template.vmid})")
return template
@router.get("/api/templates/{template_id}", response_model=OSTemplateWithGroup)
def get_os_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view"))
):
"""Получить OS шаблон по ID"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
template_dict = {
**template.__dict__,
'group_name': group.name if group else None,
'group_icon': group.icon if group else None,
'server_name': server.name if server else None,
}
template_dict.pop('_sa_instance_state', None)
return template_dict
@router.put("/api/templates/{template_id}", response_model=OSTemplateResponse)
def update_os_template(
template_id: int,
template_data: OSTemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Обновить OS шаблон"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
update_data = template_data.model_dump(exclude_unset=True)
# Validate group if changing
if 'group_id' in update_data:
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == update_data['group_id']).first()
if not group:
raise HTTPException(status_code=404, detail="Группа шаблонов не найдена")
# Validate server if changing
if 'server_id' in update_data:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == update_data['server_id']).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
logger.info(f"User {current_user.username} updated OS template: {template.name}")
return template
@router.delete("/api/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_os_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""Удалить OS шаблон"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
logger.info(f"User {current_user.username} deleted OS template: {template.name} (VMID: {template.vmid})")
db.delete(template)
db.commit()
return None
# ==================== Proxmox Templates Discovery ====================
@router.get("/api/discover/{server_id}")
def discover_proxmox_templates(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view"))
):
"""
Обнаружить все VM-шаблоны на Proxmox сервере.
Возвращает список шаблонов, которые можно добавить.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
try:
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = 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
)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
# Get templates - фильтруем по hostname ноды если это кластер
node_filter = None
if server.hostname and server.hostname != server.ip_address:
node_filter = server.hostname
proxmox_templates = client.get_templates(node=node_filter)
# Get already added templates для ЭТОГО сервера
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
result = []
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
already = (volid is not None and volid in existing_volids) or (vmid is not None and vmid in existing_vmids)
result.append({
'vmid': vmid,
'volid': volid,
'name': tpl.get('name', f"Template-{vmid or volid}"),
'node': tpl.get('node'),
'status': tpl.get('status'),
'vm_type': tpl.get('type', 'qemu'),
'is_file_template': tpl.get('is_file_template', False),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
'already_added': already,
})
return JSONResponse(content={
'server_id': server_id,
'server_name': server.name,
'templates': result
})
except Exception as e:
logger.error(f"Error discovering templates on {server.name}: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка обнаружения шаблонов: {str(e)}")
# ==================== Auto-Import Templates ====================
# Иконки используют CSS классы для адаптации к теме
_UBUNTU = {'name': 'Ubuntu', 'icon': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>', 'order': 1}
_DEBIAN = {'name': 'Debian', 'icon': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>', 'order': 2}
_CENTOS = {'name': 'CentOS', 'icon': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>', 'order': 3}
_ROCKY = {'name': 'Rocky Linux', 'icon': '<span class="os-icon-rocky-svg"></span>', 'order': 4}
_ALMA = {'name': 'AlmaLinux', 'icon': '<span class="os-icon-alma-svg"></span>', 'order': 5}
_FEDORA = {'name': 'Fedora', 'icon': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>', 'order': 6}
_RHEL = {'name': 'RHEL', 'icon': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>', 'order': 7}
_ORACLE = {'name': 'Oracle Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>', 'order': 8}
_SUSE = {'name': 'openSUSE', 'icon': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>', 'order': 9}
_ARCH = {'name': 'Arch Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>', 'order': 10}
_GENTOO = {'name': 'Gentoo', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>', 'order': 11}
_ALPINE = {'name': 'Alpine', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>', 'order': 12}
_KALI = {'name': 'Kali Linux', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-kali"></i>', 'order': 13}
_MINT = {'name': 'Linux Mint', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-mint"></i>', 'order': 14}
_WINDOWS = {'name': 'Windows', 'icon': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>', 'order': 20}
_FREEBSD = {'name': 'FreeBSD', 'icon': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>', 'order': 30}
_OPENBSD = {'name': 'OpenBSD', 'icon': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>', 'order': 31}
_OTHER = {'name': 'Other', 'icon': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>', 'order': 99}
# Маппинг ключевых слов на группы. Порядок важен — более специфичные/длинные
# ключи должны идти раньше (напр. 'oraclelinux' перед 'oracle', 'rockylinux'
# перед 'rocky'), чтобы не перехватывались более общими. Включены частые
# опечатки и варианты написания (alama/allma, redhat/red hat, sles/leap/...).
_OS_KEYWORD_GROUPS: list[tuple[tuple[str, ...], dict]] = [
(('ubuntu',), _UBUNTU),
(('debian',), _DEBIAN),
(('linuxmint', 'mint'), _MINT),
(('centos',), _CENTOS),
(('rockylinux', 'rocky'), _ROCKY),
(('almalinux', 'alma', 'alama', 'allma', 'alamalinux'), _ALMA),
(('fedora',), _FEDORA),
(('oraclelinux', 'oracle'), _ORACLE),
(('rhel', 'redhat', 'red hat', 'red-hat'), _RHEL),
(('opensuse', 'sles', 'suse', 'leap', 'tumbleweed'), _SUSE),
(('archlinux', 'arch'), _ARCH),
(('gentoo',), _GENTOO),
(('alpine',), _ALPINE),
(('kali',), _KALI),
(('windows', 'win2', 'win10', 'win11', 'winserver', 'win7', 'win8'), _WINDOWS),
(('freebsd',), _FREEBSD),
(('openbsd',), _OPENBSD),
]
# Обратная совместимость: плоский маппинг keyword → group_info.
OS_GROUP_MAPPING = {kw: info for keys, info in _OS_KEYWORD_GROUPS for kw in keys}
OS_GROUP_MAPPING['other'] = _OTHER
def detect_os_group(template_name: str) -> dict:
"""Определить группу ОС по имени шаблона (с учётом вариантов и опечаток)."""
name_lower = (template_name or '').lower()
for keys, group_info in _OS_KEYWORD_GROUPS:
if any(kw in name_lower for kw in keys):
return group_info
return _OTHER
def _do_auto_import(server_id: int, username: str):
"""
Background task: import Proxmox templates into DB.
Opens its own DB session so it can run after the request completes.
"""
db = SessionLocal()
try:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
logger.error(f"Auto-import: server {server_id} not found")
return
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = 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
)
if not client.is_connected():
logger.error(f"Auto-import: cannot connect to server {server.name}")
return
node_filter = None
if server.hostname and server.hostname != server.ip_address:
node_filter = server.hostname
proxmox_templates = client.get_templates(node=node_filter)
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
templates_by_group = {}
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
if volid and volid in existing_volids:
continue
if vmid is not None and vmid in existing_vmids:
continue
template_name = tpl.get('name', f"Template-{vmid or volid}")
group_info = detect_os_group(template_name)
group_name = group_info['name']
if group_name not in templates_by_group:
templates_by_group[group_name] = {'info': group_info, 'templates': []}
templates_by_group[group_name]['templates'].append({
'vmid': vmid,
'volid': volid,
'name': template_name,
'node': tpl.get('node'),
'vm_type': tpl.get('type', 'qemu'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
})
imported = []
global_sort_order = 0
sorted_groups = sorted(templates_by_group.items(), key=lambda x: x[1]['info']['order'])
for group_name, group_data in sorted_groups:
group_info = group_data['info']
group_templates = group_data['templates']
if group_name not in groups_cache:
new_group = OSTemplateGroup(
name=group_name,
icon=group_info['icon'],
description=f"Шаблоны {group_name}",
sort_order=group_info['order'],
is_active=True
)
db.add(new_group)
db.flush()
groups_cache[group_name] = new_group
logger.info(f"Auto-import: created group {group_name}")
group = groups_cache[group_name]
group_templates.sort(key=lambda x: x['name'])
for tpl in group_templates:
default_memory = max(1024, (tpl.get('maxmem', 0) // (1024 * 1024)) or 1024)
default_disk = max(10, (tpl.get('maxdisk', 0) // (1024 * 1024 * 1024)) or 10)
template = OSTemplate(
group_id=group.id,
server_id=server_id,
name=tpl['name'],
vmid=tpl.get('vmid'),
volid=tpl.get('volid'),
vm_type=tpl.get('vm_type', 'qemu'),
node=tpl.get('node'),
source_node=tpl.get('node'),
default_cores=2,
default_memory=default_memory,
default_disk=default_disk,
min_cores=1,
min_memory=512,
min_disk=5,
is_active=True,
sort_order=global_sort_order
)
db.add(template)
imported.append({'vmid': tpl.get('vmid'), 'volid': tpl.get('volid'), 'name': tpl['name'], 'group': group_name})
global_sort_order += 1
db.commit()
logger.info(f"Auto-import by {username}: imported {len(imported)} templates from {server.name}")
except Exception as e:
logger.error(f"Auto-import background task failed for server {server_id}: {e}")
db.rollback()
finally:
db.close()
@router.post("/api/auto-import/{server_id}")
def auto_import_templates(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage"))
):
"""
Синхронный импорт шаблонов с Proxmox сервера.
Возвращает список импортированных шаблонов и их количество.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
if server.use_password:
client = ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
client = 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
)
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
proxmox_templates = client.get_templates(node=node_filter)
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
templates_by_group = {}
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
if volid and volid in existing_volids:
continue
if vmid is not None and vmid in existing_vmids:
continue
template_name = tpl.get('name', f"Template-{vmid or volid}")
group_info = detect_os_group(template_name)
group_name = group_info['name']
if group_name not in templates_by_group:
templates_by_group[group_name] = {'info': group_info, 'templates': []}
templates_by_group[group_name]['templates'].append({
'vmid': vmid,
'volid': volid,
'name': template_name,
'node': tpl.get('node'),
'vm_type': tpl.get('type', 'qemu'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
})
imported = []
global_sort_order = 0
sorted_groups = sorted(templates_by_group.items(), key=lambda x: x[1]['info']['order'])
try:
for group_name, group_data in sorted_groups:
group_info = group_data['info']
group_templates = group_data['templates']
if group_name not in groups_cache:
new_group = OSTemplateGroup(
name=group_name,
icon=group_info['icon'],
description=f"Шаблоны {group_name}",
sort_order=group_info['order'],
is_active=True
)
db.add(new_group)
db.flush()
groups_cache[group_name] = new_group
group = groups_cache[group_name]
group_templates.sort(key=lambda x: x['name'])
for tpl in group_templates:
default_memory = max(1024, (tpl.get('maxmem', 0) // (1024 * 1024)) or 1024)
default_disk = max(10, (tpl.get('maxdisk', 0) // (1024 * 1024 * 1024)) or 10)
template = OSTemplate(
group_id=group.id,
server_id=server_id,
name=tpl['name'],
vmid=tpl.get('vmid'),
volid=tpl.get('volid'),
vm_type=tpl.get('vm_type', 'qemu'),
node=tpl.get('node'),
source_node=tpl.get('node'),
default_cores=2,
default_memory=default_memory,
default_disk=default_disk,
min_cores=1,
min_memory=512,
min_disk=5,
is_active=True,
sort_order=global_sort_order
)
db.add(template)
imported.append({
'vmid': tpl.get('vmid'),
'volid': tpl.get('volid'),
'name': tpl['name'],
'group': group_name,
'sort_order': global_sort_order
})
global_sort_order += 1
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Auto-import failed for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка импорта: {str(e)}")
logger.info(f"User {current_user.username} imported {len(imported)} templates from {server.name}")
return JSONResponse(content={
'success': True,
'server_id': server_id,
'server_name': server.name,
'imported_count': len(imported),
'templates': imported
})
# ==================== VM Deployment ====================
def _update_deploy_task(task_id: int, status: str, step: str, progress: int,
vmid: int = None, node: str = None, error: str = None):
"""Update deploy task state in DB and broadcast via WebSocket."""
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_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
user_id: int, username: str,
owner_id: int = None):
"""Blocking deploy logic — runs in a thread pool."""
db = SessionLocal()
server = None
template = None
deploy_node = None
new_vmid = None
try:
_update_deploy_task(task_id, 'running', 'Подготовка...', 5)
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
if not template:
_update_deploy_task(task_id, 'failed', 'Шаблон не найден', 0, error='Шаблон не найден')
return
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
if not server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Сервер Proxmox не найден')
return
cores = deploy_data.cores or template.default_cores
memory = deploy_data.memory or template.default_memory
disk = deploy_data.disk or template.default_disk
# Connect
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10)
if server.use_password:
client = ProxmoxClient(
host=server.ip_address, user=server.api_user,
password=server.password, verify_ssl=server.verify_ssl
)
else:
client = 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
)
if not client.is_connected():
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 10,
error='Не удалось подключиться к Proxmox серверу')
return
# Locate template
_update_deploy_task(task_id, 'running', 'Поиск шаблона...', 20)
source_node = template.get_source_node()
target_node = deploy_data.target_node or source_node
template_vmid = template.vmid
template_status = client.get_vm_status(source_node, template_vmid)
if not template_status:
found = client.find_template_on_nodes(template_vmid)
if found:
source_node = found['node']
else:
_update_deploy_task(task_id, 'failed', 'Шаблон не найден на Proxmox', 20,
error=f'VMID {template_vmid} не найден на сервере')
return
# Allocate VMID
_update_deploy_task(task_id, 'running', 'Генерация VMID...', 25)
if deploy_data.vmid:
new_vmid = deploy_data.vmid
else:
new_vmid = get_next_vmid(db, server.id)
# Clone
_update_deploy_task(task_id, 'running', f'Клонирование шаблона → VMID {new_vmid}...', 30)
clone_upid = client.clone_vm_from_template(
node=source_node,
template_vmid=template_vmid,
new_vmid=new_vmid,
name=deploy_data.name,
full_clone=True,
description=f"Deployed from template {template.name} by {username}",
target_storage=deploy_data.target_storage,
target_node=target_node if target_node != source_node else None
)
if not clone_upid:
_update_deploy_task(task_id, 'failed', 'Ошибка клонирования', 30,
error='Proxmox не вернул UPID задачи клонирования')
return
# Wait for clone — progress from 30 to 65
_update_deploy_task(task_id, 'running', 'Клонирование диска...', 45, vmid=new_vmid)
clone_success = client.wait_for_task(source_node, clone_upid, timeout=300)
if not clone_success:
_update_deploy_task(task_id, 'failed', 'Таймаут клонирования', 45,
error='Клонирование заняло слишком долго (>5 мин)')
return
deploy_node = target_node
_update_deploy_task(task_id, 'running', 'Клонирование завершено', 65, vmid=new_vmid, node=deploy_node)
# Клон может ещё держать lock (особенно cross-node) — config.put упадёт
_update_deploy_task(task_id, 'running', 'Ожидание разблокировки VM...', 66, vmid=new_vmid, node=deploy_node)
client.wait_for_vm_unlock(deploy_node, new_vmid, timeout=120)
# IPAM
allocated_ip = deploy_data.ip_address
ipam_allocation = None
if deploy_data.ipam_network_id and not deploy_data.ip_address:
_update_deploy_task(task_id, 'running', 'Выделение IP-адреса (IPAM)...', 68, vmid=new_vmid, node=deploy_node)
ipam = IPAMService(db)
ipam_allocation, error = ipam.auto_allocate_ip(
network_id=deploy_data.ipam_network_id,
pool_id=deploy_data.ipam_pool_id,
resource_type='vm',
resource_name=deploy_data.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=username,
notes=f"Auto-allocated for VM deployment from template {template.name}"
)
if not error:
allocated_ip = ipam_allocation.ip_address
if not deploy_data.gateway:
from ..models import IPAMNetwork
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == deploy_data.ipam_network_id).first()
if net and net.gateway:
deploy_data.gateway = net.gateway
elif deploy_data.ip_address and deploy_data.ipam_network_id:
ipam = IPAMService(db)
ipam_allocation, _ = ipam.allocate_ip(
ip_address=deploy_data.ip_address,
network_id=deploy_data.ipam_network_id,
pool_id=deploy_data.ipam_pool_id,
resource_type='vm',
resource_name=deploy_data.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=deploy_node,
allocated_by=username,
notes=f"Manually assigned for VM deployment from template {template.name}"
)
# Determine network mask & gateway
network_mask = "24"
gateway = deploy_data.gateway
if allocated_ip and (deploy_data.ipam_network_id or ipam_allocation):
try:
from ..models import IPAMNetwork
network_id = deploy_data.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:
if net.network and '/' in net.network:
network_mask = net.network.split('/')[1]
if not gateway and net.gateway:
gateway = net.gateway
except Exception as e:
logger.warning(f"Could not get network info from IPAM: {e}")
ip_config = None
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"
# Configure VM
_update_deploy_task(task_id, 'running', 'Настройка VM (CPU, RAM, Cloud-Init)...', 72, vmid=new_vmid, node=deploy_node)
# Ключи уже собраны и дедуплицированы в эндпоинте
# (build_deploy_ssh_keys): вставленные вручную + выбранные из
# библиотеки + профильный ключ владельца инстанса.
ssh_keys = deploy_data.ssh_keys or ""
config_result = client.configure_vm(
node=deploy_node,
vmid=new_vmid,
cores=cores,
memory=memory,
disk_size=disk,
network_bridge=deploy_data.network_bridge,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=ssh_keys if ssh_keys else None,
ip_config=ip_config,
onboot=deploy_data.onboot
)
config_warnings = None
if not config_result.get('ok'):
config_warnings = '; '.join(
f'{group}: {msg}' for group, msg in config_result.get('errors', {}).items()
)
logger.warning(f"VM {new_vmid} created but some settings failed: {config_warnings}")
# Start
if deploy_data.start_after_create:
_update_deploy_task(task_id, 'running', 'Запуск VM...', 88, vmid=new_vmid, node=deploy_node)
client.start_vm(deploy_node, new_vmid)
# HA
if deploy_data.enable_ha:
try:
if client.is_cluster():
client.add_to_ha(vmid=new_vmid, vm_type='vm', max_restart=3, max_relocate=3)
except Exception as ha_error:
logger.error(f"HA error for VM {new_vmid}: {ha_error}")
# Save to DB
_update_deploy_task(task_id, 'running', 'Сохранение в базе данных...', 95, vmid=new_vmid, node=deploy_node)
user = db.query(User).filter(User.id == user_id).first()
save_vm_instance(
db=db,
server_id=server.id,
vmid=new_vmid,
node=deploy_node,
vm_type='qemu',
name=deploy_data.name,
cores=cores,
memory=memory,
disk_size=disk,
ip_address=allocated_ip,
ip_prefix=int(network_mask),
gateway=gateway,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=deploy_data.ssh_keys,
template_id=template.id,
template_name=template.name,
description=f"Deployed from template {template.name}",
owner_id=owner_id or user_id
)
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=new_vmid,
username=username,
resource_name=deploy_data.name,
server_id=server.id,
server_name=server.name,
node_name=deploy_node,
details={"template_id": template.id, "template_name": template.name, "cores": cores,
"memory": memory, "disk": disk, "ip_address": allocated_ip},
success=True
)
if config_warnings:
_update_deploy_task(task_id, 'completed', 'VM создана с предупреждениями', 100,
vmid=new_vmid, node=deploy_node, error=config_warnings)
else:
_update_deploy_task(task_id, 'completed', 'VM успешно создана!', 100, vmid=new_vmid, node=deploy_node)
logger.info(f"[DEPLOY] Task #{task_id}: VM {deploy_data.name} (VMID {new_vmid}) deployed by {username}")
except Exception as e:
err = str(e)
logger.error(f"[DEPLOY] Task #{task_id} failed: {err}")
# Log failed
try:
if server and template:
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=deploy_data.vmid,
username=username,
resource_name=deploy_data.name,
server_id=server.id if server else None,
server_name=server.name if server else None,
node_name=deploy_node,
details={"template_id": template.id if template else None},
success=False,
error_message=err
)
except Exception:
pass
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
@router.post("/api/deploy", response_model=DeployTaskStartResponse, status_code=202)
async def deploy_vm_from_template(
deploy_data: VMDeployRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:create"))
):
"""
Асинхронный деплой VM из шаблона.
Немедленно возвращает task_id, выполнение идёт в фоне.
Статус можно отслеживать через GET /api/deploy/{task_id}.
"""
# Fast validation before queuing
template = db.query(OSTemplate).filter(OSTemplate.id == deploy_data.template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if not template.is_active:
raise HTTPException(status_code=400, detail="Шаблон неактивен")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
cores = deploy_data.cores or template.default_cores
memory = deploy_data.memory or template.default_memory
disk = deploy_data.disk or template.default_disk
if cores < template.min_cores:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_cores} ядер CPU")
if memory < template.min_memory:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_memory} MB памяти")
if disk < template.min_disk:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
# Resolve owner / SSH keys (admin may target another user)
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
instance_owner_id = current_user.id
if deploy_data.owner_id and deploy_data.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 == deploy_data.owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
from ..schemas import validate_instance_name
try:
deploy_data.name = validate_instance_name(deploy_data.name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Enforce per-user resource quota (no-op if owner has no quota set)
from ..services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
from .ssh_keys import build_deploy_ssh_keys
deploy_data.ssh_keys = build_deploy_ssh_keys(
db, current_user, instance_owner_id,
deploy_data.ssh_key_ids, deploy_data.ssh_keys,
) or None
# Create deploy task record
task = DeployTask(
status='pending',
step='В очереди...',
progress=0,
name=deploy_data.name,
template_id=template.id,
server_id=server.id,
user_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(task)
task_id = task.id
# Schedule blocking deploy in thread pool
loop = asyncio.get_event_loop()
loop.run_in_executor(
_deploy_executor,
_do_deploy_sync,
task_id,
deploy_data,
current_user.id,
current_user.username,
instance_owner_id,
)
logger.info(f"[DEPLOY] Task #{task_id} queued: {deploy_data.name} by {current_user.username}")
return DeployTaskStartResponse(task_id=task_id, status='pending', name=deploy_data.name)
@router.get("/api/deploy/{task_id}", response_model=DeployTaskStatusResponse)
async def get_deploy_task_status(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Получить статус задачи деплоя."""
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Задача не найдена")
if task.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Нет доступа")
return DeployTaskStatusResponse(**task.to_dict())
# ==================== Quick Deploy (for UI) ====================
@router.get("/api/quick-deploy/{template_id}")
def get_quick_deploy_info(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:view"))
):
"""Получить информацию для быстрого развертывания VM"""
template = db.query(OSTemplate).filter(OSTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
return JSONResponse(content={
'template': {
'id': template.id,
'name': template.name,
'vmid': template.vmid,
'node': template.node,
'description': template.description,
'default_cores': template.default_cores,
'default_memory': template.default_memory,
'default_disk': template.default_disk,
'min_cores': template.min_cores,
'min_memory': template.min_memory,
'min_disk': template.min_disk,
},
'group': {
'name': group.name if group else None,
'icon': group.icon if group else None,
},
'server': {
'id': server.id if server else None,
'name': server.name if server else None,
}
})