Files
pvemanager/backend/app/proxmox/mixins/vm.py
Dilshod Rasulov 6e67ad7e77
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 51s
Deploy to Production / Trigger production update (push) Has been cancelled
fix(disk-resize): keep growpart script ASCII-only
Proxmox's guest-agent exec (PVE/QemuServer/Agent.pm) dies with
"Wide character in subroutine entry" when input-data contains non-ASCII
bytes. The growpart script had Cyrillic comments, so the whole auto-grow
call returned 500 and the FS was never resized. Replace those comments
with ASCII so the script reaches the guest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:09:43 +05:00

356 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import List, Dict, Optional, Union, Any
import time
import urllib3
from loguru import logger
class VmMixin:
def start_vm(self, node: str, vmid: int) -> Optional[str]:
"""Запустить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка запуска VM {vmid} на {node}: {e}")
return None
def stop_vm(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
"""Остановить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.stop.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка остановки VM {vmid} на {node}: {e}")
return None
def restart_vm(self, node: str, vmid: int) -> Optional[str]:
"""Перезапустить VM. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.reboot.post()
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
return None
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox:
return None
try:
upid = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(timeout=timeout)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка корректного выключения VM {vmid} на {node}: {e}")
return None
def clone_vm(self, node: str, vmid: int, new_vmid: int, name: str,
full: bool = True, target_node: Optional[str] = None,
target_storage: Optional[str] = None,
description: Optional[str] = None) -> Optional[str]:
"""Клонировать VM (qemu). Работает и для шаблонов, и для обычных VM."""
if not self.proxmox:
return None
try:
params = {'newid': new_vmid, 'name': name, 'full': 1 if full else 0}
if target_storage:
params['storage'] = target_storage
if target_node and target_node != node:
params['target'] = target_node
if description:
params['description'] = description
upid = self.proxmox.nodes(node).qemu(vmid).clone.post(**params)
return upid if isinstance(upid, str) else None
except Exception as e:
logger.error(f"Ошибка клонирования VM {vmid} -> {new_vmid}: {e}")
return None
def change_vm_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
"""
Сменить пароль пользователя на VM через QEMU guest agent.
Требует установленный qemu-guest-agent в VM.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
try:
# Proxmox API: POST /nodes/{node}/qemu/{vmid}/agent/set-user-password
self.proxmox.nodes(node).qemu(vmid).agent('set-user-password').post(
username=username,
password=password,
)
return {'success': True}
except Exception as e:
logger.error(f"Не удалось сменить пароль на VM {vmid}: {e}")
return {'success': False, 'error': str(e)}
def set_vm_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(description=description or '')
return True
except Exception as e:
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
return False
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
"""Подключить ISO образ к VM (только KVM)."""
if not self.proxmox:
return False
try:
value = f"{iso_volid},media=cdrom"
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: value})
return True
except Exception as e:
logger.error(f"Ошибка подключения ISO {iso_volid} к VM {vmid}: {e}")
return False
def detach_iso(self, node: str, vmid: int, device: str = 'ide2') -> bool:
"""Отключить ISO образ (CD-ROM) у VM."""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: 'none,media=cdrom'})
return True
except Exception as e:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False
def force_stop_vm(self, node: str, vmid: int) -> bool:
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
if not self.proxmox or not self.host:
return False
try:
# Пробуем остановить через SSH команду
# Сначала получаем информацию о ВМ
vm_info = self.proxmox.nodes(node).qemu(vmid).status.current.get()
if vm_info.get('status') != 'running':
logger.info(f"VM {vmid} на {node} уже остановлена")
return True
# Используем qm sendkey или qm stop с timeout=0
try:
# Пробуем обычный stop - может сработать
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
logger.info(f"Принудительная остановка VM {vmid} на {node} выполнена")
return True
except Exception as api_error:
logger.warning(f"Прямой stop не сработал, ошибка: {api_error}")
# Если прямой stop не сработал, это просто значит что ВМ не бежит или API не поддерживает
return False
except Exception as e:
logger.error(f"Ошибка принудительной остановки VM {vmid}: {e}")
return False
def get_vm_status(self, node: str, vmid: int) -> Optional[Dict]:
"""Получить статус конкретной VM"""
if not self.proxmox:
return None
try:
status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
return status
except Exception as e:
logger.error(f"Ошибка получения статуса VM {vmid} на {node}: {e}")
return None
def get_vm_stats(self, node: str, vmid: int) -> Optional[Dict]:
"""Получить статистику VM (CPU, память, диск)"""
return self.get_vm_status(node, vmid)
def get_vm_fsinfo(self, node: str, vmid: int) -> list:
"""Return per-filesystem info via QEMU guest agent (get-fsinfo).
Returns [] if agent is not installed or unreachable."""
if not self.proxmox:
return []
try:
result = self.proxmox.nodes(node).qemu(vmid).agent('get-fsinfo').get()
fslist = result if isinstance(result, list) else result.get('result', [])
disks = []
seen_disks: set = set()
for fs in fslist:
if not isinstance(fs, dict):
continue
total = fs.get('total-bytes', 0) or 0
if total == 0:
continue
# Deduplicate by disk+partition key to avoid pseudo-filesystems
disk_key = fs.get('name', '') or fs.get('mountpoint', '')
if disk_key in seen_disks:
continue
seen_disks.add(disk_key)
disks.append({
'name': fs.get('name', ''),
'mountpoint': fs.get('mountpoint', '/'),
'used': fs.get('used-bytes', 0) or 0,
'total': total,
})
return disks
except Exception:
return []
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
"""
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
Args:
node: Имя ноды
vmid: ID виртуальной машины
timeframe: Период времени (hour, day, week, month, year)
Returns:
Dict с временными рядами данных
"""
if not self.proxmox:
return {}
try:
rrddata = self.proxmox.nodes(node).qemu(vmid).rrddata.get(timeframe=timeframe)
return {'data': rrddata, 'timeframe': timeframe}
except Exception as e:
logger.error(f"Ошибка получения RRD данных VM {vmid} на {node}: {e}")
return {}
def get_vm_vnc(self, node: str, vmid: int) -> Dict:
"""
Получить данные для VNC подключения к VM
Args:
node: Имя ноды
vmid: ID виртуальной машины
Returns:
Dict с данными VNC (port, ticket, cert, upid)
"""
if not self.proxmox:
return {}
try:
# Создать VNC прокси сессию
vnc_data = self.proxmox.nodes(node).qemu(vmid).vncproxy.post(websocket=1)
return vnc_data
except Exception as e:
logger.error(f"Ошибка получения VNC для VM {vmid} на {node}: {e}")
return {}
def resize_vm_disk(self, node: str, vmid: int, disk: str, size: str) -> bool:
"""
Изменить размер диска VM
Args:
node: Имя ноды
vmid: ID виртуальной машины
disk: Имя диска (scsi0, virtio0 и т.д.)
size: Новый размер (+10G, 50G)
Returns:
True при успехе
"""
if not self.proxmox:
return False
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size)
# Дожидаемся завершения задачи и ПРОВЕРЯЕМ её результат: Proxmox
# создаёт задачу даже на несуществующий диск, и она падает с
# exitstatus вроде "disk 'scsi0' does not exist". Без этой проверки
# упавший ресайз ошибочно считался успешным.
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'
logger.error(f"Ресайз диска {disk} VM {vmid} провалился: {reason}")
raise RuntimeError(f"Proxmox: {reason}")
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
return True
def grow_vm_filesystem(self, node: str, vmid: int, mountpoint: str = "/") -> Dict:
"""
Расширить раздел и файловую систему внутри гостя после ресайза диска.
Выполняется через QEMU guest agent (требуется qemu-guest-agent и
cloud-guest-utils/growpart в гостевой ОС). Best-effort: возвращает
результат, но не бросает исключений.
Args:
node: Имя ноды
vmid: ID виртуальной машины
mountpoint: Точка монтирования для расширения (по умолчанию корень)
Returns:
Dict с ключами success, stdout, stderr, exit_code (как execute_script)
"""
# Скрипт находит блочное устройство, отвечающее за mountpoint,
# расширяет его раздел через growpart и затем растягивает ФС под
# её тип (ext*/xfs/btrfs).
script = f"""#!/bin/bash
set -u
MP="{mountpoint}"
SRC="$(findmnt -nro SOURCE "$MP" 2>/dev/null)"
[ -z "$SRC" ] && SRC="$(awk -v m="$MP" '$2==m{{print $1}}' /proc/mounts | head -n1)"
# strip btrfs subvolume suffix like [/@]
SRC="${{SRC%%[*}}"
DEV="$(readlink -f "$SRC" 2>/dev/null || echo "$SRC")"
BASE="$(basename "$DEV")"
PK="$(lsblk -nro PKNAME "$DEV" 2>/dev/null | head -n1)"
PARTNUM="$(cat "/sys/class/block/$BASE/partition" 2>/dev/null)"
echo "target mount=$MP dev=$DEV disk=${{PK:-?}} part=${{PARTNUM:-?}}"
if [ -z "$PK" ] || [ -z "$PARTNUM" ]; then
echo "ERROR: cannot resolve disk/partition for $MP" >&2
exit 1
fi
DISK="/dev/$PK"
SIZE_BEFORE="$(blockdev --getsize64 "$DISK" 2>/dev/null || echo 0)"
echo "disk bytes before rescan: $SIZE_BEFORE"
# Force kernel to re-read disk capacity. Critical for SATA/SCSI where it
# is not updated live. Retry because rescan is asynchronous.
for i in 1 2 3 4 5; do
echo 1 > "/sys/class/block/$PK/device/rescan" 2>/dev/null || true
partprobe "$DISK" 2>/dev/null || true
SIZE_NOW="$(blockdev --getsize64 "$DISK" 2>/dev/null || echo 0)"
[ "$SIZE_NOW" != "$SIZE_BEFORE" ] && break
sleep 1
done
echo "disk bytes after rescan: $(blockdev --getsize64 "$DISK" 2>/dev/null)"
if ! command -v growpart >/dev/null 2>&1; then
echo "ERROR: growpart not installed (apt install cloud-guest-utils)" >&2
exit 2
fi
growpart "$DISK" "$PARTNUM"; GP=$?
echo "growpart rc=$GP"
partx -u "$DISK" 2>/dev/null || true
FSTYPE="$(findmnt -nro FSTYPE "$MP" 2>/dev/null)"
echo "fstype=$FSTYPE"
case "$FSTYPE" in
ext2|ext3|ext4) resize2fs "$DEV" ;;
xfs) xfs_growfs "$MP" ;;
btrfs) btrfs filesystem resize max "$MP" ;;
*) echo "WARN: unknown fstype '$FSTYPE', skipping fs grow" >&2 ;;
esac
echo "new fs size: $(findmnt -nro SIZE "$MP" 2>/dev/null)"
echo "grow done"
"""
try:
result = self.execute_script(node, vmid, script, timeout=120)
out = (result.get('stdout', '') or '') + (result.get('stderr', '') or '')
# growpart печатает "CHANGED:" когда раздел реально вырос
result['changed'] = 'CHANGED' in out
logger.info(
f"growpart VM {vmid}: exit={result.get('exit_code')} "
f"changed={result['changed']}\n{out.strip()}"
)
return result
except Exception as e:
logger.warning(f"Не удалось выполнить growpart в VM {vmid}: {e}")
return {'success': False, 'error': str(e), 'stdout': '', 'stderr': '', 'exit_code': -1}