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 ...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 ...logging_service import LoggingService
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()
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 ====================
def _do_change_password_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,