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:
@@ -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,
|
||||
|
||||
@@ -45,6 +45,16 @@ class ImageDownloadRequest(BaseModel):
|
||||
checksum: 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')
|
||||
def _check(self):
|
||||
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],
|
||||
'checksum': item.get('checksum'),
|
||||
'checksum_algorithm': item.get('checksum_algorithm'),
|
||||
'name': item['name'],
|
||||
'name': item['name'], 'os': item.get('os'), 'version': item.get('version'),
|
||||
}
|
||||
# Кастомное зеркало: source_id вида "mirror-<id>"
|
||||
if req.source_id.startswith('mirror-'):
|
||||
@@ -139,7 +149,7 @@ def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
|
||||
'filename': fname,
|
||||
'checksum': mirror.checksum,
|
||||
'checksum_algorithm': mirror.checksum_algorithm,
|
||||
'name': mirror.name,
|
||||
'name': mirror.name, 'os': mirror.os, 'version': mirror.version,
|
||||
}
|
||||
raise HTTPException(status_code=404, detail="Источник образа не найден")
|
||||
|
||||
@@ -151,7 +161,7 @@ def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
|
||||
return {
|
||||
'kind': kind, 'url': req.url, 'template': req.template, 'filename': filename,
|
||||
'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),
|
||||
current_user: User = Depends(PermissionChecker("template:manage")),
|
||||
):
|
||||
"""Запустить фоновую загрузку образа в хранилище ноды."""
|
||||
"""Запустить фоновую загрузку образа (и опц. конвертацию qcow2 → VM-шаблон)."""
|
||||
# Ленивый импорт во избежание циклической зависимости templates <-> api.proxmox
|
||||
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()
|
||||
if not server:
|
||||
@@ -177,9 +187,12 @@ async def download_image(
|
||||
if src['kind'] != 'vztmpl' and not src['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(
|
||||
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,
|
||||
)
|
||||
db.add(task)
|
||||
@@ -188,6 +201,17 @@ async def download_image(
|
||||
task_id = task.id
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if to_template:
|
||||
loop.run_in_executor(
|
||||
_deploy_executor,
|
||||
_do_image_template_sync,
|
||||
task_id, server_id, req.node, req.storage, req.disk_storage or req.storage,
|
||||
src['url'], src['filename'], src['checksum'], src['checksum_algorithm'],
|
||||
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,
|
||||
@@ -197,5 +221,5 @@ async def download_image(
|
||||
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'])
|
||||
|
||||
@@ -738,6 +738,55 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
logger.error(f"Ошибка преобразования VM {vmid} в шаблон: {e}")
|
||||
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]:
|
||||
"""
|
||||
Найти шаблон по VMID на всех нодах кластера
|
||||
|
||||
Reference in New Issue
Block a user