fix(deploy): apply VM config reliably after clone

- wait for clone lock release before config.put (cross-node clones kept
  lock past wait_for_task, dropping ALL requested settings silently)
- configure_vm applies params in independent groups (compute/network/
  cloudinit/disk) with per-group errors and lock retry
- set sockets=1 with cores so total vCPU matches request
- keep template net0 model/MAC/options, replace only bridge
- disk resize: pick boot disk via regex over all buses, report failures
- surface config errors as deploy warnings in UI toast instead of
  silent success

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 02:31:21 +05:00
parent e366646257
commit 7d52297f76
5 changed files with 230 additions and 90 deletions

View File

@@ -824,6 +824,10 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
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
@@ -903,7 +907,7 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
_uniq_ssh.append(_k)
ssh_keys = "\n".join(_uniq_ssh)
config_success = client.configure_vm(
config_result = client.configure_vm(
node=deploy_node,
vmid=new_vmid,
cores=cores,
@@ -916,8 +920,12 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
ip_config=ip_config,
onboot=deploy_data.onboot
)
if not config_success:
logger.warning(f"VM {new_vmid} created but configuration may be incomplete")
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:
@@ -972,7 +980,11 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
success=True
)
_update_deploy_task(task_id, 'completed', 'VM успешно создана!', 100, vmid=new_vmid, node=deploy_node)
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:

View File

@@ -891,6 +891,39 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Error replicating template to {target_node}: {e}")
return None
def wait_for_vm_unlock(self, node: str, vmid: int, timeout: int = 120) -> bool:
"""
Дождаться снятия блокировки VM (lock: clone/migrate/...)
После wait_for_task клон может ещё держать блокировку, особенно
при cross-node клонировании — config.put в этот момент падает.
Args:
node: Имя ноды
vmid: VMID VM
timeout: Таймаут в секундах
Returns:
True если блокировка снята
"""
import time as time_module
start_time = time_module.time()
while time_module.time() - start_time < timeout:
try:
config = self.proxmox.nodes(node).qemu(vmid).config.get()
if 'lock' not in config:
return True
logger.info(f"VM {vmid} заблокирована ({config.get('lock')}), ожидание разблокировки...")
except Exception as e:
# При cross-node клоне VM может ещё не зарегистрироваться на целевой ноде
if time_module.time() - start_time > 15:
logger.warning(f"VM {vmid} недоступна при ожидании разблокировки: {e}")
time_module.sleep(2)
logger.warning(f"Таймаут ожидания разблокировки VM {vmid}")
return False
def configure_vm(
self,
node: str,
@@ -905,14 +938,17 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
ssh_keys: str = None,
ip_config: str = None,
onboot: bool = None
) -> bool:
) -> Dict:
"""
Настроить параметры VM после клонирования
Параметры применяются независимыми группами (compute, network,
cloudinit, disk), чтобы ошибка одной группы не отменяла остальные.
Args:
node: Имя ноды
vmid: VMID VM
cores: Количество ядер CPU
cores: Количество ядер CPU (итоговое число vCPU, sockets=1)
memory: Память в MB
disk_size: Размер диска в GB
disk_storage: Хранилище диска
@@ -922,94 +958,178 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
ssh_keys: SSH ключи (public)
ip_config: Конфигурация IP (например, "ip=dhcp" или "ip=192.168.1.100/24,gw=192.168.1.1")
onboot: Автозапуск VM при старте хоста
Returns:
True если успешно
{"ok": bool, "applied": [группы], "errors": {группа: сообщение}}
"""
import time as time_module
result = {"ok": False, "applied": [], "errors": {}}
if not self.proxmox:
return False
result["errors"]["connection"] = "Proxmox client not connected"
return result
try:
params = {}
if cores:
params['cores'] = cores
if memory:
params['memory'] = memory
if onboot is not None:
params['onboot'] = 1 if onboot else 0
if network_bridge:
params['net0'] = f'virtio,bridge={network_bridge}'
# Cloud-init configuration
if cloud_init_user:
params['ciuser'] = cloud_init_user
if cloud_init_password:
params['cipassword'] = cloud_init_password
if ssh_keys:
# SSH keys need URL encoding
import urllib.parse
params['sshkeys'] = urllib.parse.quote(ssh_keys, safe='')
if ip_config:
params['ipconfig0'] = ip_config
if params:
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
logger.info(f"VM {vmid} настроена: {params.keys()}")
# Resize disk if needed
if disk_size:
try:
# Get current disk info
config = self.proxmox.nodes(node).qemu(vmid).config.get()
# Find the main disk (usually scsi0 or virtio0)
# Exclude ide0/ide2 which are often CD-ROM or cloud-init drives
disk_key = None
current_size_gb = 0
for key in ['scsi0', 'virtio0', 'sata0', 'scsi1', 'virtio1']:
if key in config:
disk_config = config[key]
# Skip if it's a cdrom or cloud-init drive
if 'cdrom' in disk_config.lower() or 'cloudinit' in disk_config.lower() or 'media=cdrom' in disk_config.lower():
continue
disk_key = key
# Parse current size from config (format: "storage:vm-xxx-disk-0,size=32G")
if 'size=' in disk_config:
size_part = disk_config.split('size=')[1].split(',')[0]
if size_part.endswith('G'):
current_size_gb = int(size_part[:-1])
elif size_part.endswith('M'):
current_size_gb = int(size_part[:-1]) // 1024
break
if disk_key:
# Only resize if requested size is larger than current
if disk_size > current_size_gb:
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(
disk=disk_key,
size=f'{disk_size}G'
)
# Wait for resize task to complete before proceeding
if isinstance(resize_upid, str):
self.wait_for_task(node, resize_upid, timeout=120)
logger.info(f"Диск {disk_key} VM {vmid} изменен с {current_size_gb}G до {disk_size}G")
else:
logger.info(f"Диск {disk_key} VM {vmid} уже имеет размер {current_size_gb}G >= запрошенного {disk_size}G")
else:
logger.warning(f"Не найден основной диск для VM {vmid}")
except Exception as e:
logger.warning(f"Не удалось изменить размер диска VM {vmid}: {e}")
return True
current_config = dict(self.proxmox.nodes(node).qemu(vmid).config.get())
except Exception as e:
logger.error(f"Ошибка настройки VM {vmid}: {e}")
return False
logger.warning(f"Не удалось прочитать конфигурацию VM {vmid}: {e}")
current_config = {}
def _apply(group: str, params: Dict):
for attempt in (1, 2):
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
result["applied"].append(group)
logger.info(f"VM {vmid}: группа '{group}' применена: {list(params.keys())}")
return
except Exception as e:
if attempt == 1 and 'lock' in str(e).lower():
logger.info(f"VM {vmid} заблокирована, повтор группы '{group}' через 5с")
time_module.sleep(5)
continue
result["errors"][group] = str(e)
logger.error(f"VM {vmid}: ошибка применения группы '{group}': {e}")
return
# CPU / RAM / автозапуск
compute_params = {}
if cores:
compute_params['cores'] = cores
# sockets=1 гарантирует, что итоговое число vCPU равно cores
# независимо от топологии шаблона
compute_params['sockets'] = 1
if memory:
compute_params['memory'] = memory
if onboot is not None:
compute_params['onboot'] = 1 if onboot else 0
if compute_params:
_apply('compute', compute_params)
# Сеть: сохраняем модель/MAC/опции из шаблона, меняем только bridge
if network_bridge:
existing_net0 = current_config.get('net0')
if existing_net0:
parts = [p for p in str(existing_net0).split(',') if p.strip()]
new_parts = []
replaced = False
for p in parts:
if p.startswith('bridge='):
new_parts.append(f'bridge={network_bridge}')
replaced = True
else:
new_parts.append(p)
if not replaced:
new_parts.append(f'bridge={network_bridge}')
net0_value = ','.join(new_parts)
else:
net0_value = f'virtio,bridge={network_bridge}'
_apply('network', {'net0': net0_value})
# Cloud-init
ci_params = {}
if cloud_init_user:
ci_params['ciuser'] = cloud_init_user
if cloud_init_password:
ci_params['cipassword'] = cloud_init_password
if ssh_keys:
# SSH keys need URL encoding
import urllib.parse
ci_params['sshkeys'] = urllib.parse.quote(ssh_keys, safe='')
if ip_config:
ci_params['ipconfig0'] = ip_config
if ci_params:
has_ci_drive = any(
isinstance(v, str) and 'cloudinit' in v for v in current_config.values()
)
# Если конфиг прочитать не удалось — всё равно пробуем применить
if has_ci_drive or not current_config:
_apply('cloudinit', ci_params)
else:
result["errors"]["cloudinit"] = (
"шаблон без cloud-init диска — пользователь/пароль/SSH/IP не применены"
)
logger.warning(f"VM {vmid}: нет cloud-init диска, параметры cloud-init пропущены")
# Диск
if disk_size:
disk_error = self._resize_vm_disk(node, vmid, disk_size)
if disk_error:
result["errors"]["disk"] = disk_error
logger.error(f"VM {vmid}: ошибка ресайза диска: {disk_error}")
else:
result["applied"].append('disk')
result["ok"] = not result["errors"]
return result
def _resize_vm_disk(self, node: str, vmid: int, disk_size: int) -> Optional[str]:
"""
Увеличить основной диск VM до disk_size GB.
Returns:
None при успехе (или если ресайз не нужен), иначе текст ошибки
"""
import re
def _parse_size_gb(value: str) -> int:
if 'size=' not in value:
return 0
size_part = value.split('size=')[1].split(',')[0]
try:
if size_part.endswith('T'):
return int(float(size_part[:-1]) * 1024)
if size_part.endswith('G'):
return int(float(size_part[:-1]))
if size_part.endswith('M'):
return int(float(size_part[:-1])) // 1024
return int(size_part) // (1024 ** 3)
except ValueError:
return 0
try:
config = self.proxmox.nodes(node).qemu(vmid).config.get()
candidates = {}
for key, value in config.items():
if not re.match(r'^(scsi|virtio|sata|ide)\d+$', key):
continue
str_value = str(value)
if 'media=cdrom' in str_value.lower() or 'cloudinit' in str_value.lower():
continue
candidates[key] = str_value
if not candidates:
return f'основной диск VM {vmid} не найден'
# Предпочитаем загрузочный диск из boot=order=...
disk_key = None
boot_match = re.search(r'order=([^,;]+(?:;[^,;]+)*)', str(config.get('boot', '')))
if boot_match:
for dev in boot_match.group(1).split(';'):
if dev in candidates:
disk_key = dev
break
if not disk_key:
disk_key = sorted(candidates)[0]
current_size_gb = _parse_size_gb(candidates[disk_key])
if disk_size <= current_size_gb:
logger.info(f"Диск {disk_key} VM {vmid} уже имеет размер {current_size_gb}G >= запрошенного {disk_size}G")
return None
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(
disk=disk_key,
size=f'{disk_size}G'
)
if isinstance(resize_upid, str):
if not self.wait_for_task(node, resize_upid, timeout=120):
status = self.get_task_status(node, resize_upid) or {}
reason = status.get('exitstatus') or 'resize task failed'
return f'ресайз {disk_key} не удался: {reason}'
logger.info(f"Диск {disk_key} VM {vmid} изменен с {current_size_gb}G до {disk_size}G")
return None
except Exception as e:
return str(e)
def get_task_status(self, node: str, upid: str) -> Dict:
"""Get status of a Proxmox task by UPID"""