feat(instances): ISO eject/mount with boot-order switch and hybrid reboot
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

- Images: show real ISO files already on the node storage (incl. those
  present before the node joined the panel) via existing node/isos endpoint
- ISO dialog: rename "detach" to "Eject", show current image inside the
  select (drop overflowing side text), add "boot from disk" checkbox
- Backend: on eject, put disk first in boot order; on mount, optional
  "boot from this ISO" puts CD-ROM first
- Reboot to apply the new boot order via hybrid restart: graceful ACPI
  shutdown with forceStop fallback, then start (fixes guest-ping timeout
  on freshly installed OS without QEMU Guest Agent)
- Update iso_next_steps hint to reflect the automated flow
This commit is contained in:
markmorado
2026-07-24 23:00:32 +05:00
parent aa100b49c1
commit b7c3a6ed35
8 changed files with 311 additions and 37 deletions

View File

@@ -3649,10 +3649,18 @@ class NotesRequest(BaseModel):
class IsoAttachRequest(BaseModel):
volid: str
device: str = "ide2"
# Поставить этот ISO первым в порядке загрузки (грузиться с образа: live-CD и т.п.).
boot_from_iso: bool = False
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
reboot_after: bool = True
class IsoDetachRequest(BaseModel):
device: str = "ide2"
# После извлечения ISO — поставить диск первым в порядке загрузки (грузиться с ОС).
boot_from_disk: bool = True
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
reboot_after: bool = True
class ExecuteCommandRequest(BaseModel):
@@ -3750,16 +3758,29 @@ def attach_iso_endpoint(
require_vm_access(db, current_user, server_id, vmid)
server = _resolve_server(db, server_id)
client = _get_client_or_503(server)
ok = client.attach_iso(node, vmid, body.volid, body.device or 'ide2')
device = body.device or 'ide2'
ok = client.attach_iso(node, vmid, body.volid, device)
if not ok:
raise HTTPException(status_code=500, detail="Failed to attach ISO")
boot_iso_set = False
rebooted = False
if body.boot_from_iso:
boot_iso_set = client.set_boot_iso_first(node, vmid, device)
# Новый порядок загрузки применяется только при power-cycle QEMU.
# Перезапускаем лишь запущенную ВМ.
if boot_iso_set and body.reboot_after:
status = client.get_vm_status(node, vmid) or {}
if status.get('status') == 'running':
rebooted = client.hybrid_restart_vm(node, vmid)
LoggingService.log_proxmox_action(
db=db, action='attach-iso', resource_type='vm', resource_id=vmid,
username=current_user.username, server_id=server_id,
server_name=server.name, node_name=node,
details={'volid': body.volid, 'device': body.device}, success=True,
details={'volid': body.volid, 'device': device,
'boot_from_iso': boot_iso_set, 'rebooted': rebooted},
success=True,
)
return {"status": "success"}
return {"status": "success", "boot_from_iso": boot_iso_set, "rebooted": rebooted}
@router.post("/api/{server_id}/vm/{vmid}/iso/detach")
@@ -3775,10 +3796,28 @@ def detach_iso_endpoint(
require_vm_access(db, current_user, server_id, vmid)
server = _resolve_server(db, server_id)
client = _get_client_or_503(server)
ok = client.detach_iso(node, vmid, body.device or 'ide2')
device = body.device or 'ide2'
ok = client.detach_iso(node, vmid, device)
if not ok:
raise HTTPException(status_code=500, detail="Failed to detach ISO")
return {"status": "success"}
boot_disk_set = False
rebooted = False
if body.boot_from_disk:
boot_disk_set = client.set_boot_disk_first(node, vmid, exclude_device=device)
# Новый порядок загрузки применяется только при power-cycle QEMU (гостевой
# reboot конфиг не перечитывает). Перезапускаем лишь запущенную ВМ.
if boot_disk_set and body.reboot_after:
status = client.get_vm_status(node, vmid) or {}
if status.get('status') == 'running':
rebooted = client.hybrid_restart_vm(node, vmid)
LoggingService.log_proxmox_action(
db=db, action='detach-iso', resource_type='vm', resource_id=vmid,
username=current_user.username, server_id=server_id,
server_name=server.name, node_name=node,
details={'device': device, 'boot_from_disk': boot_disk_set, 'rebooted': rebooted},
success=True,
)
return {"status": "success", "boot_from_disk": boot_disk_set, "rebooted": rebooted}
# ══════════════════════════════════════════════════════════════════════════

View File

@@ -1,4 +1,5 @@
from typing import List, Dict, Optional, Union, Any
import re
import time
import urllib3
from loguru import logger
@@ -40,6 +41,46 @@ class VmMixin:
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
return None
def _wait_vm_stopped(self, node: str, vmid: int, timeout: int) -> bool:
"""Опрашивать статус ВМ, пока не 'stopped' или не выйдет timeout секунд."""
deadline = time.time() + timeout
while time.time() < deadline:
st = self.get_vm_status(node, vmid) or {}
if st.get('status') == 'stopped':
return True
time.sleep(1)
return False
def hybrid_restart_vm(self, node: str, vmid: int,
graceful_timeout: int = 20) -> bool:
"""Перезапуск «мягко, при таймауте — жёстко», затем start.
status/shutdown с forceStop=1 сначала шлёт ACPI-выключение, а если гость
не ответил за graceful_timeout секунд — Proxmox сам убивает процесс QEMU.
Это покрывает и живую ОС (корректное выключение), и свежий установщик без
ACPI/QEMU Guest Agent (где обычный reboot виснет на guest-ping). Нужен для
применения нового boot order. Возвращает True, если ВМ снова запущена.
"""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(
timeout=graceful_timeout, forceStop=1)
# Ждём с запасом сверх graceful_timeout (форс-стоп внутри shutdown).
if not self._wait_vm_stopped(node, vmid, graceful_timeout + 20):
# Подстраховка: shutdown завис — бьём по процессу напрямую.
logger.warning(f"VM {vmid}: shutdown не завершился, принудительный stop")
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
if not self._wait_vm_stopped(node, vmid, 15):
logger.error(f"VM {vmid} не остановилась — старт отменён")
return False
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
logger.info(f"VM {vmid} на {node}: гибридный перезапуск выполнен, UPID: {upid}")
return isinstance(upid, str)
except Exception as e:
logger.error(f"Ошибка гибридного перезапуска VM {vmid} на {node}: {e}")
return False
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox:
@@ -204,6 +245,81 @@ class VmMixin:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False
def _ordered_boot_disks(self, cfg: Dict,
exclude_device: Optional[str] = None) -> List[str]:
"""Загрузочные диски ВМ (не CD-ROM, не пустые) в порядке текущего boot.
Порядок между дисками берём из текущего boot=order=...; недостающие
добавляем в конец. exclude_device исключается принудительно. efidisk/
tpmstate не проходят regex дисковых устройств, поэтому не попадают.
"""
disk_re = re.compile(r'^(?:scsi|virtio|sata|ide)\d+$')
disks: List[str] = []
for key, val in cfg.items():
if not disk_re.match(key):
continue
if exclude_device and key == exclude_device:
continue
if not isinstance(val, str) or not val:
continue
if 'media=cdrom' in val or val.startswith('none'):
continue
disks.append(key)
ordered: List[str] = []
m = re.search(r'order=([^,]+)', str(cfg.get('boot') or ''))
if m:
for dev in m.group(1).split(';'):
if dev in disks and dev not in ordered:
ordered.append(dev)
for dev in disks:
if dev not in ordered:
ordered.append(dev)
return ordered
def set_boot_disk_first(self, node: str, vmid: int,
exclude_device: Optional[str] = None) -> bool:
"""Поставить дисковые устройства первыми в порядке загрузки, исключив CD-ROM.
Используется после установки ОС: извлекли ISO — грузимся с диска.
exclude_device (например отключаемый ide2) исключается из рассмотрения.
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
ordered = self._ordered_boot_disks(cfg, exclude_device=exclude_device)
if not ordered:
logger.warning(f"VM {vmid}: не найден загрузочный диск для boot order")
return False
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(ordered)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → диск ({';'.join(ordered)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (диск) у VM {vmid}: {e}")
return False
def set_boot_iso_first(self, node: str, vmid: int,
iso_device: str = 'ide2') -> bool:
"""Поставить CD-ROM (ISO) первым в порядке загрузки, диски — следом.
Используется при монтировании ISO на работающую ВМ, когда нужно
загрузиться именно с образа (live-CD, восстановление, переустановка).
iso_device — привод с ISO (ide2 по умолчанию).
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
disks = self._ordered_boot_disks(cfg) # CD-ROM сюда не попадёт
order = [iso_device] + [d for d in disks if d != iso_device]
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(order)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → ISO ({';'.join(order)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (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:

View File

@@ -13,8 +13,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useServers, useNodes } from '@/hooks/use-nodes';
import { useImageCatalog, useImageLXCTemplates, useNodeArch } from '@/hooks/use-image-catalog';
import { useImageCatalog, useImageLXCTemplates, useNodeArch, useNodeIsos } from '@/hooks/use-image-catalog';
import { useProfile } from '@/hooks/use-settings';
import { formatBytes } from '@/lib/format';
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
import DownloadIsoDialog, { type IsoSource } from './DownloadIsoDialog';
import MirrorsManager from './MirrorsManager';
@@ -47,6 +48,7 @@ export default function ImagesPage() {
const { data: catalog } = useImageCatalog();
const { data: lxcRepo = [] } = useImageLXCTemplates(serverId ?? undefined, node || undefined);
const { data: nodeArch } = useNodeArch(serverId ?? undefined, node || undefined);
const { data: nodeIsosResp } = useNodeIsos(serverId ?? undefined, node || undefined);
// Авто-выбор первого сервера и ноды
useEffect(() => {
@@ -78,6 +80,14 @@ export default function ImagesPage() {
&& (!q || `${img.name} ${img.os ?? ''} ${img.version ?? ''}`.toLowerCase().includes(q)));
}, [catalog, search]);
// Реальные ISO-файлы на хранилищах выбранной ноды (в т.ч. существовавшие
// до подключения ноды к панели). Фильтр по поисковой строке.
const nodeIsos = useMemo(() => {
const all = nodeIsosResp?.isos ?? [];
const q = search.toLowerCase();
return all.filter((iso) => !q || `${iso.name} ${iso.volid}`.toLowerCase().includes(q));
}, [nodeIsosResp, search]);
const openIsoDownload = (src: IsoSource | null) => {
setIsoSource(src);
setIsoDialogOpen(true);
@@ -184,6 +194,31 @@ export default function ImagesPage() {
{!canDownload && (
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы загрузить образ.</div>
)}
{/* Реальные ISO на хранилище ноды (загруженные ранее, в т.ч. до подключения к панели) */}
{canDownload && nodeIsos.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">На хранилище ноды</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{nodeIsos.map((iso) => (
<Card key={iso.volid}>
<CardContent className="flex items-center gap-3 p-4">
<OsLogo name={iso.name} />
<div className="min-w-0 flex-1">
<div className="font-medium truncate" title={iso.name}>{iso.name}</div>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant="outline">{iso.storage}</Badge>
{iso.size != null && <Badge variant="secondary">{formatBytes(iso.size)}</Badge>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Сохранённые ISO-ссылки из каталога/зеркал */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{isoImages.map((img) => (
<Card key={img.id}>

View File

@@ -818,19 +818,26 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const [device, setDevice] = useState('ide2');
const [volid, setVolid] = useState<string>('');
const [bootFromDisk, setBootFromDisk] = useState(true);
const [bootFromIso, setBootFromIso] = useState(false);
const isQemu = type === 'qemu';
const isos = useNodeIsos(serverId, node, open && isQemu);
const config = useVMConfig(serverId, vmid, type, node, open && isQemu);
const attach = useAttachIso(serverId, vmid, node);
const detach = useDetachIso(serverId, vmid, node);
const currentIso = useMemo(() => {
// volid текущего подключённого образа (без ',media=cdrom,size=...'); null — привод пуст.
const currentVolid = useMemo(() => {
const v = config.data?.[device];
if (typeof v !== 'string') return null;
if (v.startsWith('none')) return null;
return v;
if (typeof v !== 'string' || v.startsWith('none')) return null;
return v.split(',')[0] || null;
}, [config.data, device]);
// Отразить текущий образ в выпадающем списке при открытии/смене устройства.
useEffect(() => {
if (open) setVolid(currentVolid || '');
}, [open, device, currentVolid]);
if (!isQemu) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@@ -855,22 +862,17 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Устройство</Label>
<Select value={device} onValueChange={(v) => v && setDevice(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ide0">ide0</SelectItem>
<SelectItem value="ide1">ide1</SelectItem>
<SelectItem value="ide2">ide2</SelectItem>
<SelectItem value="ide3">ide3</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-xs text-muted-foreground self-end">
Сейчас: <span className="font-mono">{currentIso || 'пусто'}</span>
</div>
<div>
<Label>Устройство</Label>
<Select value={device} onValueChange={(v) => v && setDevice(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ide0">ide0</SelectItem>
<SelectItem value="ide1">ide1</SelectItem>
<SelectItem value="ide2">ide2</SelectItem>
<SelectItem value="ide3">ide3</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Образ</Label>
@@ -880,7 +882,9 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
</SelectTrigger>
<SelectContent>
{(isos.data?.isos || []).map((iso) => (
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem>
<SelectItem key={iso.volid} value={iso.volid}>
{iso.name || iso.volid}{iso.volid === currentVolid ? ' · текущий' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -888,22 +892,68 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p>
)}
</div>
{currentVolid && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
className="mt-0.5"
checked={bootFromDisk}
onChange={(e) => setBootFromDisk(e.target.checked)}
/>
<span>
Загружаться с диска после извлечения
<span className="block text-xs text-muted-foreground">
Ставит диск первым в порядке загрузки и перезапускает ВМ, если она запущена, —
чтобы сразу стартовать с установленной ОС, а не с ISO.
</span>
</span>
</label>
)}
{volid && volid !== currentVolid && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
className="mt-0.5"
checked={bootFromIso}
onChange={(e) => setBootFromIso(e.target.checked)}
/>
<span>
Загрузиться с этого ISO
<span className="block text-xs text-muted-foreground">
Ставит CD-ROM первым в порядке загрузки и перезапускает ВМ, если она запущена, —
для установки/восстановления с образа. Без галочки ISO просто вставится в привод.
</span>
</span>
</label>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => detach.mutate({ device }, {
onSuccess: () => { toast.success('ISO отключён'); onClose(); },
onClick={() => detach.mutate({ device, boot_from_disk: bootFromDisk, reboot_after: bootFromDisk }, {
onSuccess: (r) => {
toast.success(
r.rebooted ? 'ISO извлечён, ВМ перезапускается с диска'
: r.boot_from_disk ? 'ISO извлечён, загрузка с диска (применится при старте)'
: 'ISO отключён',
);
onClose();
},
onError: (e) => toast.error(e.message),
})}
disabled={detach.isPending || !currentIso}
disabled={detach.isPending || !currentVolid}
>
{detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Отключить
Извлечь
</Button>
<Button
onClick={() => attach.mutate({ volid, device }, {
onSuccess: () => { toast.success('ISO подключён'); onClose(); },
onClick={() => attach.mutate({ volid, device, boot_from_iso: bootFromIso, reboot_after: bootFromIso }, {
onSuccess: (r) => {
toast.success(
r.rebooted ? 'ISO подключён, ВМ перезапускается с образа'
: r.boot_from_iso ? 'ISO подключён, загрузка с образа (применится при старте)'
: 'ISO подключён',
);
onClose();
},
onError: (e) => toast.error(e.message),
})}
disabled={!volid || attach.isPending}

View File

@@ -50,6 +50,28 @@ export function useNodeArch(serverId?: number, node?: string) {
});
}
// Реальные ISO-образы, уже лежащие на ISO-хранилищах ноды (в т.ч. загруженные
// до подключения ноды к панели). Отдельно от каталога/зеркал — это содержимое storage.
export interface NodeIso {
volid: string;
storage: string;
size: number | null;
format: string | null;
name: string;
}
export function useNodeIsos(serverId?: number, node?: string) {
return useQuery({
queryKey: ['node-isos', serverId, node],
queryFn: () =>
apiClient.get<{ isos: NodeIso[] }>(
`/proxmox/api/${serverId}/node/${encodeURIComponent(node!)}/isos`,
),
enabled: !!serverId && !!node,
staleTime: 60 * 1000,
});
}
// Целевые хранилища ноды по типу контента (import|vztmpl|iso).
export function useImageStorages(serverId?: number, node?: string, content?: string) {
return useQuery({

View File

@@ -826,22 +826,34 @@ export function useNodeIsos(serverId: number, node: string, enabled = true) {
export function useAttachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { volid: string; device?: string }>({
return useMutation<
{ status: string; boot_from_iso?: boolean; rebooted?: boolean },
Error,
{ volid: string; device?: string; boot_from_iso?: boolean; reboot_after?: boolean }
>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useDetachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { device?: string }>({
return useMutation<
{ status: string; boot_from_disk?: boolean; rebooted?: boolean },
Error,
{ device?: string; boot_from_disk?: boolean; reboot_after?: boolean }
>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['resources'] });
},
});
}

View File

@@ -617,7 +617,7 @@
"os_type": "Guest OS",
"os_type_hint": "Changing this presets BIOS, disk bus and network card",
"iso_ip_hint": "The IP is only reserved in IPAM — there is no cloud-init here, so enter it manually in the installer.",
"iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, unmount the ISO and put the disk first in the boot order.",
"iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, click Eject in the ISO dialog — the disk becomes first in the boot order automatically.",
"iso_download": "Download an ISO to this node",
"iso_refresh": "Refresh the ISO list"
},

View File

@@ -617,7 +617,7 @@
"os_type": "Гостевая ОС",
"os_type_hint": "Выбор ОС подставляет BIOS, шину диска и сетевую карту",
"iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.",
"iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё отключите ISO и поставьте диск первым в порядке загрузки.",
"iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё нажмите «Извлечь» в диалоге ISO — диск автоматически станет первым в порядке загрузки.",
"iso_download": "Загрузить ISO на эту ноду",
"iso_refresh": "Обновить список ISO"
},