feat(images): Phase 2 — auto-convert qcow2 to VM template

- client.create_vm_with_import: API-only disk import via scsi0 import-from (PVE 8.2+)
- _do_image_template_sync worker: download(import) -> create+import -> cloud-init
  -> convert_to_template -> register OSTemplate (Cloud Images group)
- images download endpoint: to_template branch + cloud-init/disk_storage params
- clear fallback error when storage lacks import support (file kept)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:11:20 +05:00
parent 32e06fe922
commit c7edf72d19
3 changed files with 228 additions and 16 deletions

View File

@@ -14,7 +14,7 @@ from typing import Optional
from loguru import logger from loguru import logger
from ...db import SessionLocal from ...db import SessionLocal
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User from ...models import ProxmoxServer, VMInstance, OSTemplate, OSTemplateGroup, DeployTask, User
from ...proxmox import ProxmoxClient from ...proxmox import ProxmoxClient
from ...logging_service import LoggingService from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save_vm_instance from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save_vm_instance
@@ -642,6 +642,145 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
db.close() db.close()
def _get_or_create_cloud_group(db) -> int:
"""Найти/создать группу шаблонов «Cloud Images» для импортированных образов."""
grp = db.query(OSTemplateGroup).filter(OSTemplateGroup.name == 'Cloud Images').first()
if not grp:
grp = OSTemplateGroup(name='Cloud Images', icon='cloud',
description='Импортированные cloud-образы', sort_order=50)
db.add(grp)
db.commit()
db.refresh(grp)
return grp.id
def _do_image_template_sync(task_id: int, server_id: int, node: str,
import_storage: str, disk_storage: str,
url: str, filename: str,
checksum: Optional[str], checksum_algorithm: Optional[str],
name: str, cores: int, memory: int, bridge: str,
ciuser: Optional[str], cipassword: Optional[str],
ssh_keys: Optional[str], os_name: Optional[str],
version: Optional[str], user_id: int, username: str):
"""Скачать qcow2 cloud-образ и автоматически конвертировать в Proxmox VM-шаблон.
Пайплайн (без SSH, только API PVE 8.2+):
download-url(content=import) -> qemu.post(scsi0 import-from) -> cloud-init
-> convert_to_template -> регистрация OSTemplate.
"""
db = SessionLocal()
server = None
new_vmid: Optional[int] = None
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 5, node=node)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Proxmox сервер не найден')
return
client = _connect(server)
if not client:
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 5,
error='Не удалось подключиться к Proxmox серверу')
return
# 1) Загрузка образа в import-хранилище
_update_deploy_task(task_id, 'running', f'Загрузка образа {filename}...', 10, node=node)
try:
dl_upid = client.download_url(node, import_storage, url, 'import', filename,
checksum=checksum, checksum_algorithm=checksum_algorithm)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка запуска загрузки', 10,
error=f'Download start failed: {e}')
return
if not dl_upid or not _wait_download_with_progress(client, task_id, node, dl_upid, lo=10, hi=40):
_update_deploy_task(task_id, 'failed', 'Загрузка образа не удалась', 10,
error='Не удалось скачать образ')
return
import_volid = f'{import_storage}:import/{filename}'
# 2) Аллокация VMID и создание ВМ с импортом диска
_update_deploy_task(task_id, 'running', 'Аллокация VMID...', 42, node=node)
new_vmid = client.get_next_vmid() or get_next_vmid(db, server_id)
if not new_vmid:
_update_deploy_task(task_id, 'failed', 'Не удалось выделить VMID', 42, error='Failed to allocate VMID')
return
_update_deploy_task(task_id, 'running', f'Создание ВМ {new_vmid} и импорт диска...', 45,
vmid=new_vmid, node=node)
try:
cr_upid = client.create_vm_with_import(
node, new_vmid, name, memory, cores, disk_storage, import_volid, bridge=bridge,
)
except Exception as e:
# Чаще всего — хранилище не поддерживает import-from (старый PVE / нет import-storage).
_update_deploy_task(
task_id, 'failed', 'Импорт диска не поддержан', 45,
vmid=new_vmid, node=node,
error=("Хранилище ноды не поддерживает импорт диска "
"(нужен PVE 8.2+ с import-capable dir/NFS storage). "
f"qcow2 скачан в {import_storage}, авто-конвертация пропущена. {str(e)[:120]}"),
)
return
if cr_upid:
_wait_download_with_progress(client, task_id, node, cr_upid, lo=45, hi=85)
# 3) Cloud-init + базовая настройка
_update_deploy_task(task_id, 'running', 'Настройка cloud-init...', 90, vmid=new_vmid, node=node)
try:
client.configure_vm(
node, new_vmid,
cloud_init_user=ciuser or None,
cloud_init_password=cipassword or None,
ssh_keys=ssh_keys or None,
ip_config='ip=dhcp',
)
except Exception as _ce:
logger.warning(f"[IMG TPL #{task_id}] cloud-init config failed: {_ce}")
# 4) Преобразование в шаблон
_update_deploy_task(task_id, 'running', 'Преобразование в шаблон...', 95, vmid=new_vmid, node=node)
if not client.convert_to_template(node, new_vmid):
_update_deploy_task(task_id, 'failed', 'Не удалось пометить шаблоном', 95,
vmid=new_vmid, node=node,
error='convert_to_template вернул ошибку')
return
# 5) Регистрация OSTemplate (становится доступен в деплое)
_update_deploy_task(task_id, 'running', 'Регистрация шаблона...', 98, vmid=new_vmid, node=node)
try:
group_id = _get_or_create_cloud_group(db)
tpl = OSTemplate(
group_id=group_id, server_id=server_id, name=name,
vmid=new_vmid, vm_type='qemu', node=node, source_node=node,
default_cores=cores, default_memory=memory, default_disk=10,
description=f'Импортирован из {filename}'
+ (f' ({os_name} {version})' if os_name else ''),
)
db.add(tpl)
db.commit()
except Exception as reg_err:
logger.warning(f"[IMG TPL #{task_id}] OSTemplate register failed: {reg_err}")
LoggingService.log_proxmox_action(
db=db, action='image_template',
resource_type='vm', resource_id=new_vmid,
username=username, resource_name=name,
server_id=server_id, server_name=server.name, node_name=node,
details={'filename': filename, 'disk_storage': disk_storage}, success=True,
)
_update_deploy_task(task_id, 'completed', f'Шаблон готов (VMID {new_vmid})', 100,
vmid=new_vmid, node=node)
logger.info(f"[IMG TPL #{task_id}] {filename} -> template VMID {new_vmid} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[IMG TPL #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, vmid=new_vmid, error=err)
finally:
db.close()
# ==================== Change Password ==================== # ==================== Change Password ====================
def _do_change_password_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str, def _do_change_password_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,

View File

@@ -45,6 +45,16 @@ class ImageDownloadRequest(BaseModel):
checksum: Optional[str] = None checksum: Optional[str] = None
checksum_algorithm: Optional[str] = None checksum_algorithm: Optional[str] = None
# Авто-конвертация qcow2 → VM-шаблон (Phase 2)
to_template: bool = False
disk_storage: Optional[str] = None # хранилище диска ВМ (content=images); по умолч. = storage
cores: Optional[int] = None
memory: Optional[int] = None # MB
bridge: Optional[str] = None
ciuser: Optional[str] = None
cipassword: Optional[str] = None
ssh_keys: Optional[str] = None
@model_validator(mode='after') @model_validator(mode='after')
def _check(self): def _check(self):
if not self.source_id and not (self.url or self.template): if not self.source_id and not (self.url or self.template):
@@ -123,7 +133,7 @@ def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
'filename': item.get('filename') or (item.get('url') or '').rsplit('/', 1)[-1], 'filename': item.get('filename') or (item.get('url') or '').rsplit('/', 1)[-1],
'checksum': item.get('checksum'), 'checksum': item.get('checksum'),
'checksum_algorithm': item.get('checksum_algorithm'), 'checksum_algorithm': item.get('checksum_algorithm'),
'name': item['name'], 'name': item['name'], 'os': item.get('os'), 'version': item.get('version'),
} }
# Кастомное зеркало: source_id вида "mirror-<id>" # Кастомное зеркало: source_id вида "mirror-<id>"
if req.source_id.startswith('mirror-'): if req.source_id.startswith('mirror-'):
@@ -139,7 +149,7 @@ def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
'filename': fname, 'filename': fname,
'checksum': mirror.checksum, 'checksum': mirror.checksum,
'checksum_algorithm': mirror.checksum_algorithm, 'checksum_algorithm': mirror.checksum_algorithm,
'name': mirror.name, 'name': mirror.name, 'os': mirror.os, 'version': mirror.version,
} }
raise HTTPException(status_code=404, detail="Источник образа не найден") raise HTTPException(status_code=404, detail="Источник образа не найден")
@@ -151,7 +161,7 @@ def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
return { return {
'kind': kind, 'url': req.url, 'template': req.template, 'filename': filename, 'kind': kind, 'url': req.url, 'template': req.template, 'filename': filename,
'checksum': req.checksum, 'checksum_algorithm': req.checksum_algorithm, 'checksum': req.checksum, 'checksum_algorithm': req.checksum_algorithm,
'name': filename, 'name': filename, 'os': None, 'version': None,
} }
@@ -162,10 +172,10 @@ async def download_image(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage")), current_user: User = Depends(PermissionChecker("template:manage")),
): ):
"""Запустить фоновую загрузку образа в хранилище ноды.""" """Запустить фоновую загрузку образа (и опц. конвертацию qcow2 → VM-шаблон)."""
# Ленивый импорт во избежание циклической зависимости templates <-> api.proxmox # Ленивый импорт во избежание циклической зависимости templates <-> api.proxmox
from ..templates import _deploy_executor from ..templates import _deploy_executor
from .async_ops import _do_image_download_sync from .async_ops import _do_image_download_sync, _do_image_template_sync
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server: if not server:
@@ -177,9 +187,12 @@ async def download_image(
if src['kind'] != 'vztmpl' and not src['url']: if src['kind'] != 'vztmpl' and not src['url']:
raise HTTPException(status_code=400, detail="Для qcow2 нужен url") raise HTTPException(status_code=400, detail="Для qcow2 нужен url")
to_template = req.to_template and src['kind'] == 'qcow2'
kind = 'image_template' if to_template else 'image_download'
task = DeployTask( task = DeployTask(
status='pending', step='В очереди...', progress=0, status='pending', step='В очереди...', progress=0,
kind='image_download', name=src['name'], kind=kind, name=src['name'],
server_id=server_id, user_id=current_user.id, node=req.node, server_id=server_id, user_id=current_user.id, node=req.node,
) )
db.add(task) db.add(task)
@@ -188,14 +201,25 @@ async def download_image(
task_id = task.id task_id = task.id
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.run_in_executor( if to_template:
_deploy_executor, loop.run_in_executor(
_do_image_download_sync, _deploy_executor,
task_id, server_id, req.node, req.storage, _do_image_template_sync,
src['kind'], src['filename'], src['url'], src['template'], task_id, server_id, req.node, req.storage, req.disk_storage or req.storage,
src['checksum'], src['checksum_algorithm'], src['url'], src['filename'], src['checksum'], src['checksum_algorithm'],
current_user.id, current_user.username, src['name'], req.cores or 2, req.memory or 2048, req.bridge or 'vmbr0',
) req.ciuser, req.cipassword, req.ssh_keys, src.get('os'), src.get('version'),
current_user.id, current_user.username,
)
else:
loop.run_in_executor(
_deploy_executor,
_do_image_download_sync,
task_id, server_id, req.node, req.storage,
src['kind'], src['filename'], src['url'], src['template'],
src['checksum'], src['checksum_algorithm'],
current_user.id, current_user.username,
)
logger.info(f"[IMG DL] Task #{task_id} queued: {src['filename']} by {current_user.username}") logger.info(f"[IMG DL] Task #{task_id} ({kind}) queued: {src['filename']} by {current_user.username}")
return ImageDownloadResponse(task_id=task_id, status='pending', name=src['name']) return ImageDownloadResponse(task_id=task_id, status='pending', name=src['name'])

View File

@@ -738,6 +738,55 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Ошибка преобразования VM {vmid} в шаблон: {e}") logger.error(f"Ошибка преобразования VM {vmid} в шаблон: {e}")
return False return False
def create_vm_with_import(self, node: str, vmid: int, name: str,
memory: int, cores: int,
disk_storage: str, import_volid: str,
bridge: str = 'vmbr0', ostype: str = 'l26') -> Optional[str]:
"""
Создать ВМ, импортируя готовый диск из скачанного образа (PVE 8.2+).
Использует синтаксис диска `<storage>:0,import-from=<volid>`, при котором
Proxmox сам аллоцирует диск нужного размера и конвертирует образ —
аналог `qm importdisk` без SSH. Добавляет cloud-init drive и serial-консоль.
Args:
node: имя ноды
vmid: VMID новой ВМ
name: имя ВМ
memory: память в MB
cores: число ядер
disk_storage: хранилище для диска ВМ (content=images)
import_volid: volid скачанного образа, напр. 'local:import/img.qcow2'
bridge: сетевой мост
ostype: тип ОС (l26 — Linux 2.6+/3.x/4.x/5.x/6.x)
Returns:
UPID задачи создания/импорта или None
"""
if not self.proxmox:
return None
params = {
'vmid': vmid,
'name': name,
'memory': memory,
'cores': cores,
'sockets': 1,
'cpu': 'host',
'scsihw': 'virtio-scsi-single',
'ostype': ostype,
'scsi0': f'{disk_storage}:0,import-from={import_volid}',
'ide2': f'{disk_storage}:cloudinit',
'boot': 'order=scsi0',
'serial0': 'socket',
'vga': 'serial0',
'agent': 'enabled=1',
'net0': f'virtio,bridge={bridge}',
}
# Исключение пробрасываем наверх — воркер отличит «импорт не поддержан»
result = self.proxmox.nodes(node).qemu.post(**params)
logger.info(f"Создание ВМ {vmid} ({name}) с импортом {import_volid} на {node}, UPID: {result}")
return result
def find_template_on_nodes(self, template_vmid: int, nodes: List[str] = None) -> Optional[Dict]: def find_template_on_nodes(self, template_vmid: int, nodes: List[str] = None) -> Optional[Dict]:
""" """
Найти шаблон по VMID на всех нодах кластера Найти шаблон по VMID на всех нодах кластера