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 deploy_node = target_node
_update_deploy_task(task_id, 'running', 'Клонирование завершено', 65, vmid=new_vmid, node=deploy_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 # IPAM
allocated_ip = deploy_data.ip_address allocated_ip = deploy_data.ip_address
ipam_allocation = None ipam_allocation = None
@@ -903,7 +907,7 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
_uniq_ssh.append(_k) _uniq_ssh.append(_k)
ssh_keys = "\n".join(_uniq_ssh) ssh_keys = "\n".join(_uniq_ssh)
config_success = client.configure_vm( config_result = client.configure_vm(
node=deploy_node, node=deploy_node,
vmid=new_vmid, vmid=new_vmid,
cores=cores, cores=cores,
@@ -916,8 +920,12 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
ip_config=ip_config, ip_config=ip_config,
onboot=deploy_data.onboot onboot=deploy_data.onboot
) )
if not config_success: config_warnings = None
logger.warning(f"VM {new_vmid} created but configuration may be incomplete") 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 # Start
if deploy_data.start_after_create: if deploy_data.start_after_create:
@@ -972,6 +980,10 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
success=True 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) _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}") logger.info(f"[DEPLOY] Task #{task_id}: VM {deploy_data.name} (VMID {new_vmid}) deployed by {username}")

View File

@@ -891,6 +891,39 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Error replicating template to {target_node}: {e}") logger.error(f"Error replicating template to {target_node}: {e}")
return None 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( def configure_vm(
self, self,
node: str, node: str,
@@ -905,14 +938,17 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
ssh_keys: str = None, ssh_keys: str = None,
ip_config: str = None, ip_config: str = None,
onboot: bool = None onboot: bool = None
) -> bool: ) -> Dict:
""" """
Настроить параметры VM после клонирования Настроить параметры VM после клонирования
Параметры применяются независимыми группами (compute, network,
cloudinit, disk), чтобы ошибка одной группы не отменяла остальные.
Args: Args:
node: Имя ноды node: Имя ноды
vmid: VMID VM vmid: VMID VM
cores: Количество ядер CPU cores: Количество ядер CPU (итоговое число vCPU, sockets=1)
memory: Память в MB memory: Память в MB
disk_size: Размер диска в GB disk_size: Размер диска в GB
disk_storage: Хранилище диска disk_storage: Хранилище диска
@@ -924,92 +960,176 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
onboot: Автозапуск VM при старте хоста onboot: Автозапуск VM при старте хоста
Returns: Returns:
True если успешно {"ok": bool, "applied": [группы], "errors": {группа: сообщение}}
""" """
import time as time_module
result = {"ok": False, "applied": [], "errors": {}}
if not self.proxmox: if not self.proxmox:
return False result["errors"]["connection"] = "Proxmox client not connected"
return result
try: try:
params = {} current_config = dict(self.proxmox.nodes(node).qemu(vmid).config.get())
except Exception as e:
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: if cores:
params['cores'] = cores compute_params['cores'] = cores
# sockets=1 гарантирует, что итоговое число vCPU равно cores
# независимо от топологии шаблона
compute_params['sockets'] = 1
if memory: if memory:
params['memory'] = memory compute_params['memory'] = memory
if onboot is not None: if onboot is not None:
params['onboot'] = 1 if onboot else 0 compute_params['onboot'] = 1 if onboot else 0
if compute_params:
_apply('compute', compute_params)
# Сеть: сохраняем модель/MAC/опции из шаблона, меняем только bridge
if network_bridge: if network_bridge:
params['net0'] = f'virtio,bridge={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 configuration # Cloud-init
ci_params = {}
if cloud_init_user: if cloud_init_user:
params['ciuser'] = cloud_init_user ci_params['ciuser'] = cloud_init_user
if cloud_init_password: if cloud_init_password:
params['cipassword'] = cloud_init_password ci_params['cipassword'] = cloud_init_password
if ssh_keys: if ssh_keys:
# SSH keys need URL encoding # SSH keys need URL encoding
import urllib.parse import urllib.parse
params['sshkeys'] = urllib.parse.quote(ssh_keys, safe='') ci_params['sshkeys'] = urllib.parse.quote(ssh_keys, safe='')
if ip_config: if ip_config:
params['ipconfig0'] = 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 params: # Диск
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
logger.info(f"VM {vmid} настроена: {params.keys()}")
# Resize disk if needed
if disk_size: if disk_size:
try: disk_error = self._resize_vm_disk(node, vmid, disk_size)
# Get current disk info if disk_error:
config = self.proxmox.nodes(node).qemu(vmid).config.get() result["errors"]["disk"] = disk_error
# Find the main disk (usually scsi0 or virtio0) logger.error(f"VM {vmid}: ошибка ресайза диска: {disk_error}")
# Exclude ide0/ide2 which are often CD-ROM or cloud-init drives else:
disk_key = None result["applied"].append('disk')
current_size_gb = 0
for key in ['scsi0', 'virtio0', 'sata0', 'scsi1', 'virtio1']: result["ok"] = not result["errors"]
if key in config: return result
disk_config = config[key]
# Skip if it's a cdrom or cloud-init drive def _resize_vm_disk(self, node: str, vmid: int, disk_size: int) -> Optional[str]:
if 'cdrom' in disk_config.lower() or 'cloudinit' in disk_config.lower() or 'media=cdrom' in disk_config.lower(): """
continue Увеличить основной диск VM до disk_size GB.
disk_key = key
# Parse current size from config (format: "storage:vm-xxx-disk-0,size=32G") Returns:
if 'size=' in disk_config: None при успехе (или если ресайз не нужен), иначе текст ошибки
size_part = disk_config.split('size=')[1].split(',')[0] """
if size_part.endswith('G'): import re
current_size_gb = int(size_part[:-1])
elif size_part.endswith('M'): def _parse_size_gb(value: str) -> int:
current_size_gb = int(size_part[:-1]) // 1024 if 'size=' not in value:
break 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
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( resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(
disk=disk_key, disk=disk_key,
size=f'{disk_size}G' size=f'{disk_size}G'
) )
# Wait for resize task to complete before proceeding
if isinstance(resize_upid, str): if isinstance(resize_upid, str):
self.wait_for_task(node, resize_upid, timeout=120) 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") logger.info(f"Диск {disk_key} VM {vmid} изменен с {current_size_gb}G до {disk_size}G")
else: return None
logger.info(f"Диск {disk_key} VM {vmid} уже имеет размер {current_size_gb}G >= запрошенного {disk_size}G")
else:
logger.warning(f"Не найден основной диск для VM {vmid}")
except Exception as e: except Exception as e:
logger.warning(f"Не удалось изменить размер диска VM {vmid}: {e}") return str(e)
return True
except Exception as e:
logger.error(f"Ошибка настройки VM {vmid}: {e}")
return False
def get_task_status(self, node: str, upid: str) -> Dict: def get_task_status(self, node: str, upid: str) -> Dict:
"""Get status of a Proxmox task by UPID""" """Get status of a Proxmox task by UPID"""

View File

@@ -271,6 +271,12 @@ export default function InstancesPage() {
error_message: data.error_message ?? undefined, error_message: data.error_message ?? undefined,
}); });
if (data.status === 'completed') { if (data.status === 'completed') {
if (data.error_message) {
toast.warning(
t('instances.deploy_completed_with_warnings', 'ВМ создана, но часть настроек не применилась'),
{ description: data.error_message, duration: 15000 },
);
}
setTimeout(() => removeTask(task.id), 5000); setTimeout(() => removeTask(task.id), 5000);
} }
} catch { /* ignore */ } } catch { /* ignore */ }

View File

@@ -247,6 +247,7 @@
"all_nodes": "All nodes", "all_nodes": "All nodes",
"all_servers": "All servers", "all_servers": "All servers",
"no_instances": "No instances found", "no_instances": "No instances found",
"deploy_completed_with_warnings": "VM created, but some settings were not applied",
"try_other_search": "Try a different search query", "try_other_search": "Try a different search query",
"create_first": "Create your first instance", "create_first": "Create your first instance",
"showing": "Showing", "showing": "Showing",

View File

@@ -247,6 +247,7 @@
"all_nodes": "Все узлы", "all_nodes": "Все узлы",
"all_servers": "Все серверы", "all_servers": "Все серверы",
"no_instances": "Инстансы не найдены", "no_instances": "Инстансы не найдены",
"deploy_completed_with_warnings": "ВМ создана, но часть настроек не применилась",
"try_other_search": "Попробуйте другой поисковый запрос", "try_other_search": "Попробуйте другой поисковый запрос",
"create_first": "Создайте свой первый инстанс", "create_first": "Создайте свой первый инстанс",
"showing": "Показано", "showing": "Показано",