fix(disk-resize): honest task status, auto-grow FS, CRLF-safe guest exec

Backend fixes for VM/LXC disk resize:
- resize_vm_disk/resize_container_disk now honor wait_for_task and raise
  the real Proxmox exitstatus (e.g. "disk 'scsi0' does not exist") instead
  of always returning success.
- add grow_vm_filesystem: after a VM resize, rescan the disk (retry loop,
  needed for SATA/SCSI), growpart the partition and resize the FS
  (ext*/xfs/btrfs) via the guest agent; surface its output to the API.
- execute_script normalizes CRLF/CR to LF so pasted scripts and growpart
  don't break on trailing \r ("partition-number must be a number").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 02:43:03 +05:00
parent 1a17ceed82
commit 38d485be4b
3 changed files with 135 additions and 23 deletions

View File

@@ -1390,9 +1390,32 @@ async def resize_vm_disk(
logger.info(f"Updated disk size in database: VM {vmid} -> {size_gb}GB")
logger.info(f"User {current_user.username} resized disk {disk} of VM {vmid} to {size}")
# Автоматически расширяем раздел и ФС внутри гостя (best-effort).
# Требует qemu-guest-agent и growpart в гостевой ОС; при их
# отсутствии ресайз диска всё равно считается успешным.
grow = client.grow_vm_filesystem(node, vmid)
grew = bool(grow.get('changed'))
# Включаем и error: если вызов guest agent упал с исключением,
# stdout/stderr пустые, и без error пользователь видел пустой блок.
grow_output = (
(grow.get('stdout', '') or '')
+ (grow.get('stderr', '') or '')
+ (grow.get('error', '') or '')
).strip() or 'growpart не вернул вывода (qemu-guest-agent не отвечает?)'
if grew:
message = f"Размер диска {disk} изменен на {size}. Файловая система автоматически расширена."
else:
message = (
f"Размер диска {disk} изменен на {size}, но файловую систему "
f"расширить не удалось — см. вывод growpart."
)
return JSONResponse(content={
"status": "success",
"message": f"Размер диска {disk} изменен на {size}. Для применения изменений внутри ОС выполните resize файловой системы."
"status": "success",
"message": message,
"filesystem_grown": grew,
"grow_output": grow_output,
})
else:
raise HTTPException(status_code=500, detail="Не удалось изменить размер диска")

View File

@@ -1248,16 +1248,17 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
if not self.proxmox:
return False
try:
resize_upid = self.proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size)
# Wait for resize task to complete to avoid lock conflicts
if isinstance(resize_upid, str):
self.wait_for_task(node, resize_upid, timeout=120)
logger.info(f"Диск {disk} LXC {vmid} изменен на {size}")
return True
except Exception as e:
logger.error(f"Ошибка изменения размера диска LXC {vmid}: {e}")
return False
resize_upid = self.proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size)
# Проверяем результат задачи: упавший ресайз не должен считаться
# успешным (см. resize_vm_disk).
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} LXC {vmid} провалился: {reason}")
raise RuntimeError(f"Proxmox: {reason}")
logger.info(f"Диск {disk} LXC {vmid} изменен на {size}")
return True
def get_vm_interfaces(self, node: str, vmid: int) -> List[Dict]:
"""
@@ -1476,7 +1477,12 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
'stderr': '',
'exit_code': -1
}
# Нормализуем переводы строк CRLF/CR -> LF. Скрипты из браузерного
# textarea часто приходят с \r, и тогда каждая команда ломается
# ("$'cmd\\r': command not found", "growpart 1\\r" и т.п.).
script_content = script_content.replace('\r\n', '\n').replace('\r', '\n')
try:
# Передаем скрипт напрямую интерпретатору через stdin (input-data).
# Интерпретатор запускается без -c, читая тело скрипта со stdin,

View File

@@ -259,14 +259,97 @@ class VmMixin:
if not self.proxmox:
return False
try:
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size)
# Wait for resize task to complete to avoid lock conflicts
if isinstance(resize_upid, str):
self.wait_for_task(node, resize_upid, timeout=120)
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
return True
except Exception as e:
logger.error(f"Ошибка изменения размера диска VM {vmid}: {e}")
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)"
# Для btrfs убираем суффикс подтома вида [/@]
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"
# Заставляем ядро перечитать новую ёмкость диска. Критично для SATA/SCSI:
# capacity не обновляется на лету. Повторяем, т.к. rescan асинхронный.
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}