Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/BootOrder.php
2025-11-30 11:57:22 +01:00

753 lines
23 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Illuminate\Support\Str;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
class BootOrder extends Component
{
public array $availableDevices = [];
public array $currentOrder = [];
public array $order = [];
public ?string $cdDrive = null; // Keep for backward compatibility, but prefer cdDrives
public array $cdDrives = [];
public array $isoOptions = [];
public ?string $selectedIso = null; // Keep for backward compatibility
public ?string $currentIso = null; // Keep for backward compatibility
public array $selectedIsos = [];
public array $currentIsos = [];
/**
* Boot configuration retrieved from the server.
*/
protected array $rawConfig = [];
public function mount(Server $server): void
{
parent::mount($server);
$this->loadBootConfiguration(false);
}
public function refreshBootConfiguration()
{
$this->loadBootConfiguration();
$this->notify('Boot devices refreshed successfully.', 'success');
}
public function resetOrder()
{
$this->order = $this->currentOrder;
}
public function addDevice(string $device): void
{
if (!isset($this->availableDevices[$device])) {
$this->notify('Device is no longer available.', 'error');
return;
}
if (in_array($device, $this->order)) {
return;
}
$this->order[] = $device;
}
public function removeDevice(string $device): void
{
$this->order = array_values(array_filter(
$this->order,
fn ($item) => $item !== $device
));
}
public function moveDevice(string $device, string $direction): void
{
$index = array_search($device, $this->order);
if ($index === false) {
return;
}
if ($direction === 'up' && $index > 0) {
[$this->order[$index - 1], $this->order[$index]] = [$this->order[$index], $this->order[$index - 1]];
} elseif ($direction === 'down' && $index < count($this->order) - 1) {
[$this->order[$index + 1], $this->order[$index]] = [$this->order[$index], $this->order[$index + 1]];
}
}
public function saveOrder()
{
try {
$proxmox = new Proxmox;
// If order is empty, we need to delete the boot parameter or set it to empty
if (empty($this->order)) {
// Delete the boot order parameter to reset to default
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
'delete' => 'boot',
]
);
$this->notify('Boot order reset to default. The new sequence will be used on the next power event.', 'success');
} else {
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
'boot' => 'order=' . implode(';', $this->order),
]
);
$this->notify('Boot order updated. The new sequence will be used on the next power event.', 'success');
}
$this->currentOrder = $this->order;
} catch (\Exception $e) {
$this->notify('Failed to update boot order: ' . $e->getMessage(), 'error');
}
}
public function createCdDrive()
{
if (count($this->cdDrives) >= 2) {
$this->notify('Maximum of 2 CD/DVD-ROM drives allowed.', 'info');
return;
}
$proxmox = new Proxmox;
$slot = null;
$lastError = null;
// Try IDE slots first (ide0-ide4, up to 5 attempts)
for ($i = 0; $i < 5; $i++) {
$ideSlot = 'ide' . $i;
// Skip if this slot already exists as a CD-ROM drive
if (in_array($ideSlot, $this->cdDrives, true)) {
continue;
}
// Skip if slot exists and is already configured (not empty)
if (isset($this->rawConfig[$ideSlot]) && $this->rawConfig[$ideSlot] !== '') {
// Check if it's a CD-ROM drive
if (is_string($this->rawConfig[$ideSlot]) && str_contains($this->rawConfig[$ideSlot], 'media=cdrom')) {
// Already a CD-ROM, skip
continue;
}
// Slot exists but is occupied by another device type, skip
continue;
}
// Try to create the drive on this slot
try {
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
$ideSlot => 'none,media=cdrom',
]
);
$slot = $ideSlot;
break; // Success, exit loop
} catch (\Exception $e) {
// Slot creation failed, try next slot
$lastError = $e->getMessage();
continue;
}
}
// If no IDE slot worked, try SATA slots (sata0-sata5)
if (!$slot) {
for ($i = 0; $i < 6; $i++) {
$sataSlot = 'sata' . $i;
// Skip if this slot already exists as a CD-ROM drive
if (in_array($sataSlot, $this->cdDrives, true)) {
continue;
}
// Skip if slot exists and is already configured
if (isset($this->rawConfig[$sataSlot]) && $this->rawConfig[$sataSlot] !== '') {
if (is_string($this->rawConfig[$sataSlot]) && str_contains($this->rawConfig[$sataSlot], 'media=cdrom')) {
continue;
}
continue;
}
// Try to create the drive on this slot
try {
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
$sataSlot => 'none,media=cdrom',
]
);
$slot = $sataSlot;
break; // Success, exit loop
} catch (\Exception $e) {
// Slot creation failed, try next slot
$lastError = $e->getMessage();
continue;
}
}
}
if ($slot) {
$this->notify('CD/DVD-ROM drive created on ' . strtoupper($slot) . '.', 'success');
$this->loadBootConfiguration(false);
} else {
$errorMessage = $lastError
? 'Failed to create CD/DVD-ROM drive: ' . $lastError
: 'No available IDE or SATA slots remain for a CD/DVD-ROM drive.';
$this->notify($errorMessage, 'error');
}
}
public function removeCdDrive(string $drive)
{
if (!in_array($drive, $this->cdDrives, true)) {
$this->notify('Drive not found.', 'error');
return;
}
try {
$proxmox = new Proxmox;
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
'delete' => $drive,
]
);
// Remove the drive from boot order if it exists
if (in_array($drive, $this->order, true)) {
$this->order = array_values(array_filter(
$this->order,
fn ($item) => $item !== $drive
));
// Also update currentOrder to reflect the change
$this->currentOrder = array_values(array_filter(
$this->currentOrder,
fn ($item) => $item !== $drive
));
}
$this->notify('CD/DVD-ROM drive removed from ' . strtoupper($drive) . '.', 'success');
$this->loadBootConfiguration(false);
} catch (\Exception $e) {
$this->notify('Failed to remove CD/DVD-ROM drive: ' . $e->getMessage(), 'error');
}
}
public function applyIso(?string $drive = null)
{
$targetDrive = $drive ?? $this->cdDrive;
if (!$targetDrive || !in_array($targetDrive, $this->cdDrives, true)) {
$this->notify('Create a CD/DVD-ROM drive first.', 'error');
return;
}
$selectedIso = $drive
? ($this->selectedIsos[$drive] ?? 'none')
: ($this->selectedIso ?: 'none');
$iso = $selectedIso ?: 'none';
$value = $iso === 'none' ? 'none,media=cdrom' : $iso . ',media=cdrom';
try {
$proxmox = new Proxmox;
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
'put',
[
$targetDrive => $value,
]
);
$message = $iso === 'none'
? 'ISO detached from CD/DVD-ROM drive on ' . strtoupper($targetDrive) . '.'
: ('ISO attached to ' . strtoupper($targetDrive) . ': ' . $this->formatIsoLabel($iso));
$this->notify($message, 'success');
if ($drive) {
$this->currentIsos[$drive] = $iso;
} else {
$this->currentIso = $iso;
}
$this->loadBootConfiguration(false);
} catch (\Exception $e) {
$this->notify('Failed to update ISO selection: ' . $e->getMessage(), 'error');
}
}
public function ejectIso(?string $drive = null): void
{
$targetDrive = $drive ?? $this->cdDrive;
if (!$targetDrive || !in_array($targetDrive, $this->cdDrives, true)) {
$this->notify('Create a CD/DVD-ROM drive first.', 'error');
return;
}
if ($drive) {
$this->selectedIsos[$drive] = 'none';
} else {
$this->selectedIso = 'none';
}
$this->applyIso($targetDrive);
}
public function getHasChangesProperty(): bool
{
return $this->order !== $this->currentOrder;
}
protected function loadBootConfiguration(bool $force = true): void
{
try {
$proxmox = new Proxmox;
$config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'];
$this->rawConfig = $config;
$this->availableDevices = $this->extractBootableDevices($config);
$order = $this->parseBootOrder($config);
$order = array_values(array_filter(
$order,
fn ($device) => !$this->shouldHideDevice($device, $config)
));
if ($force || empty($this->order)) {
$this->order = $order;
}
$this->currentOrder = $order;
$this->cdDrives = $this->detectCdromDevices($config);
// For backward compatibility, set first drive as cdDrive
$this->cdDrive = !empty($this->cdDrives) ? $this->cdDrives[0] : null;
// Load ISO options if we have any drives
if (!empty($this->cdDrives)) {
$this->loadIsoOptionsList();
// Initialize selected and current ISOs for each drive
$this->selectedIsos = [];
$this->currentIsos = [];
foreach ($this->cdDrives as $drive) {
$iso = $this->extractIsoFromConfig($config[$drive] ?? null);
$this->selectedIsos[$drive] = $iso;
$this->currentIsos[$drive] = $iso;
}
// For backward compatibility
if ($this->cdDrive) {
$this->selectedIso = $this->selectedIsos[$this->cdDrive] ?? 'none';
$this->currentIso = $this->currentIsos[$this->cdDrive] ?? 'none';
}
} else {
$this->selectedIso = null;
$this->currentIso = null;
$this->selectedIsos = [];
$this->currentIsos = [];
$this->isoOptions = [];
}
} catch (\Exception $e) {
$this->availableDevices = [];
$this->order = [];
$this->currentOrder = [];
$this->cdDrive = null;
$this->cdDrives = [];
$this->isoOptions = [];
$this->selectedIso = null;
$this->currentIso = null;
$this->selectedIsos = [];
$this->currentIsos = [];
$this->notify('Unable to load boot information: ' . $e->getMessage(), 'error');
}
}
/**
* Extract a normalized list of bootable devices from the VM config.
*/
protected function extractBootableDevices(array $config): array
{
$devices = [];
$pattern = '/^(ide|sata|scsi|virtio|net|efidisk|usb|nvme|tpmstate|sd)\d+$/';
foreach ($config as $key => $value) {
if (!is_string($key)) {
continue;
}
if (preg_match($pattern, $key)) {
if ($this->shouldHideDevice($key, $config, $value)) {
continue;
}
$devices[$key] = [
'label' => $this->formatDeviceLabel($key, $value),
'details' => $this->formatDeviceDetails($value),
'raw' => $value,
];
}
}
// Ensure currently configured boot devices remain visible even if missing.
foreach ($this->parseBootOrder($config) as $device) {
if ($this->shouldHideDevice($device, $config)) {
continue;
}
if (!isset($devices[$device])) {
$devices[$device] = [
'label' => strtoupper($device),
'details' => 'Device referenced in boot order but not present in config.',
'raw' => null,
];
}
}
ksort($devices);
return $devices;
}
protected function formatDeviceLabel(string $device, mixed $value): string
{
$type = preg_replace('/\d+$/', '', $device);
$map = [
'virtio' => 'VirtIO Disk',
'scsi' => 'SCSI Disk',
'sata' => 'SATA Disk',
'ide' => 'IDE Device',
'net' => 'Network Adapter',
'efidisk' => 'EFI Disk',
'usb' => 'USB Device',
'nvme' => 'NVMe Disk',
'tpmstate' => 'TPM State',
'sd' => 'Secure Digital',
];
$label = ($map[$type] ?? Str::upper($type)) . ' (' . Str::upper($device) . ')';
if (is_string($value) && str_contains($value, 'media=cdrom')) {
$label .= ' • CD/DVD';
}
return $label;
}
protected function formatDeviceDetails(mixed $value): ?string
{
if (!is_string($value) || trim($value) === '') {
return null;
}
$segments = array_map('trim', explode(',', $value));
$path = array_shift($segments);
$details = [];
foreach ($segments as $segment) {
if (str_contains($segment, '=')) {
[$key, $val] = array_map('trim', explode('=', $segment, 2));
$details[] = ucfirst($key) . ': ' . $val;
} elseif ($segment !== '') {
$details[] = $segment;
}
}
$detailString = implode(', ', $details);
if ($path && $detailString) {
return $path . ' • ' . $detailString;
}
return $path ?: ($detailString ?: null);
}
protected function parseBootOrder(array $config): array
{
$boot = $config['boot'] ?? '';
$order = [];
if (is_string($boot) && str_contains($boot, 'order=')) {
[, $list] = explode('order=', $boot, 2);
$order = array_filter(array_map('trim', explode(';', $list)));
} elseif (is_string($boot) && preg_match('/^[cdn]{1,3}$/i', $boot)) {
$order = $this->mapLegacyOrder($boot, $config);
}
if (empty($order) && isset($config['bootdisk'])) {
$order[] = $config['bootdisk'];
}
return array_values(array_filter($order));
}
protected function mapLegacyOrder(string $legacy, array $config): array
{
$legacy = strtolower($legacy);
$order = [];
$disk = $config['bootdisk'] ?? $this->detectDiskDevice($config);
$cdrom = $this->detectCdromDevice($config);
$network = $this->detectNetworkDevice($config);
foreach (str_split($legacy) as $flag) {
if ($flag === 'c' && $disk) {
$order[] = $disk;
} elseif ($flag === 'd' && $cdrom) {
$order[] = $cdrom;
} elseif ($flag === 'n' && $network) {
$order[] = $network;
}
}
return array_values(array_unique(array_filter($order)));
}
protected function shouldHideDevice(string $device, array $config, mixed $value = null): bool
{
$value ??= $config[$device] ?? null;
$deviceLower = strtolower($device);
if (is_array($value)) {
$valueLower = strtolower(json_encode($value));
} elseif (is_string($value)) {
$valueLower = strtolower($value);
} else {
$valueLower = strtolower((string) $value);
}
if (
str_contains($deviceLower, 'cloudinit')
|| str_contains($valueLower, 'cloudinit')
) {
return true;
}
if (
str_starts_with($deviceLower, 'tpmstate')
|| str_contains($valueLower, 'tpm')
) {
return true;
}
if (str_starts_with($deviceLower, 'efidisk')) {
return true;
}
return false;
}
protected function detectDiskDevice(array $config): ?string
{
foreach ($config as $key => $value) {
if (preg_match('/^(virtio|scsi|sata|ide|nvme|sd)\d+$/', $key)) {
if (is_string($value) && str_contains($value, 'media=cdrom')) {
continue;
}
return $key;
}
}
return null;
}
protected function detectCdromDevice(array $config): ?string
{
$drives = $this->detectCdromDevices($config);
return !empty($drives) ? $drives[0] : null;
}
protected function detectCdromDevices(array $config): array
{
$drives = [];
foreach ($config as $key => $value) {
if (!preg_match('/^(ide|sata)\d+$/', $key) || !is_string($value)) {
continue;
}
if (!str_contains($value, 'media=cdrom')) {
continue;
}
if (str_contains(strtolower($value), 'cloudinit')) {
continue;
}
$drives[] = $key;
}
// Sort drives to maintain consistent order (ide0, ide1, sata0, sata1, etc.)
usort($drives, function($a, $b) {
// Extract type and number for comparison
preg_match('/^(ide|sata)(\d+)$/', $a, $matchA);
preg_match('/^(ide|sata)(\d+)$/', $b, $matchB);
// Sort by type first (ide before sata), then by number
if ($matchA[1] !== $matchB[1]) {
return strcmp($matchA[1], $matchB[1]);
}
return (int)$matchA[2] <=> (int)$matchB[2];
});
return $drives;
}
protected function detectNetworkDevice(array $config): ?string
{
foreach ($config as $key => $value) {
if (preg_match('/^net\d+$/', $key)) {
return $key;
}
}
return null;
}
protected function findNextIdeSlot(array $config): ?string
{
for ($i = 0; $i < 4; $i++) {
$slot = 'ide' . $i;
if (!array_key_exists($slot, $config)) {
return $slot;
}
}
return null;
}
protected function findNextCdromSlot(array $config): ?string
{
// First, try to find an available IDE slot (ide0-ide3)
for ($i = 0; $i < 4; $i++) {
$slot = 'ide' . $i;
if (!array_key_exists($slot, $config)) {
return $slot;
}
}
// If all IDE slots are taken, try SATA slots (sata0-sata5)
for ($i = 0; $i < 6; $i++) {
$slot = 'sata' . $i;
if (!array_key_exists($slot, $config)) {
return $slot;
}
}
return null;
}
protected function loadIsoOptionsList(): void
{
try {
$proxmox = new Proxmox;
$response = $proxmox->request(
'/nodes/' . $this->server->node->name . '/storage/cephfs/content',
data: [
'content' => 'iso',
]
);
$items = $response->json()['data'] ?? [];
$options = array_map(function ($item) {
$volid = $item['volid'] ?? '';
return [
'value' => $volid,
'label' => $this->formatIsoLabel($volid),
];
}, $items);
usort($options, fn ($a, $b) => strcmp($a['label'], $b['label']));
array_unshift($options, [
'value' => 'none',
'label' => 'None',
]);
$this->isoOptions = $options;
} catch (\Exception $e) {
$this->isoOptions = [
[
'value' => 'none',
'label' => 'None',
],
];
$this->notify('Unable to fetch ISO images from cephfs: ' . $e->getMessage(), 'error');
}
}
protected function formatIsoLabel(?string $volid): string
{
if (!$volid || $volid === 'none') {
return 'None';
}
if (str_contains($volid, ':')) {
[, $path] = explode(':', $volid, 2);
$path = ltrim($path, '/');
} else {
$path = $volid;
}
return Str::of($path)->after('iso/')->value();
}
protected function extractIsoFromConfig(?string $value): string
{
if (!$value) {
return 'none';
}
$segments = array_map('trim', explode(',', $value));
$path = array_shift($segments);
if (!$path || strtolower($path) === 'none') {
return 'none';
}
return $path;
}
public function render()
{
return view('proxmox::boot-order')->layoutData([
'sidebar' => true,
]);
}
}