server = $server; if ($server->user_id != auth()->id()) { abort(403); } $this->loadVmStatus(); } /** * Perform a VM lifecycle action (start/stop/etc). */ public function do(string $action): void { if (!in_array($action, ['start', 'shutdown', 'reboot', 'stop', 'reset'], true)) { $this->notify('Invalid action', 'error'); return; } // Check storage quota before starting the VM if ($action === 'start') { $quotaCheck = $this->checkStorageQuota(); if ($quotaCheck !== true) { $this->notify($quotaCheck, 'error'); return; } } try { $proxmox = new Proxmox; $response = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/' . $action, 'post', ['vmid' => $this->server->vm_id] ); if ($response->successful()) { $this->notify('Action performed successfully', 'success'); if (property_exists($this, 'fastPolling')) { $this->fastPolling = true; } } else { $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; $this->notify('Action failed: ' . $message, 'error'); } } catch (\Throwable $e) { $this->notify('Action failed: ' . $e->getMessage(), 'error'); } finally { $this->loadVmStatus(); } } /** * Check if storage quota allows VM to start. * Returns true if OK, or an error message string if over quota. */ protected function checkStorageQuota(): bool|string { try { // Get max allowed storage from package $settings = array_merge( ExtensionHelper::settingsToArray($this->server->service->product->settings), ExtensionHelper::getServiceProperties($this->server->service) ); $maxAllowedStorage = isset($settings['disk']) ? (float) $settings['disk'] : 0; // If no limit is set, allow start if ($maxAllowedStorage <= 0) { return true; } // Get VM config to calculate total storage $proxmox = new Proxmox; $configResponse = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' ); if (!$configResponse->successful()) { // If we can't check, allow the action (fail open) return true; } $config = $configResponse->json()['data'] ?? []; $totalStorage = $this->calculateTotalStorage($config); if ($totalStorage > $maxAllowedStorage) { return 'Cannot start VM: Storage quota exceeded. Your server has ' . number_format($totalStorage, 1) . ' GB of storage configured, but your plan only allows ' . number_format($maxAllowedStorage, 1) . ' GB. Please configure your server storage within your package limits or upgrade your service to allow this configuration.'; } return true; } catch (\Throwable $e) { // If check fails, allow the action (fail open) return true; } } /** * Calculate total storage from VM config (including unused/detached drives). * Uses the same calculation method as the Storage page. */ protected function calculateTotalStorage(array $config): float { $totalSize = 0; foreach ($config as $key => $value) { // Check for attached drives (ide, sata, scsi, virtio) if (preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $key)) { // Skip excluded disks (cloud-init, EFI, TPM) if ($this->isExcludedDisk($value)) { continue; } // Skip CD-ROM drives if (str_contains($value, 'media=cdrom')) { continue; } $totalSize += $this->extractDiskSize($value); } // Check for unused/detached drives elseif (preg_match('/^unused(\d+)$/', $key)) { if ($this->isExcludedDisk($value)) { continue; } $totalSize += $this->extractUnusedDiskSize($value); } } return $totalSize; } /** * Check if a disk should be excluded (cloud-init, EFI, TPM). * Uses regex patterns matching the Storage page implementation. */ protected function isExcludedDisk(string $value): bool { $excludePatterns = [ 'cloudinit', 'vm-\d+-cloudinit', 'efidisk', 'tpmstate', ]; foreach ($excludePatterns as $pattern) { if (preg_match('/' . $pattern . '/i', $value)) { return true; } } return false; } /** * Extract disk size in GB from a disk configuration string. * Matches the Storage page parseDiskString logic. */ protected function extractDiskSize(string $value): float { // Example: cephpool:vm-100-disk-0,size=32G,cache=none,ssd=1 $parts = explode(',', $value); foreach ($parts as $part) { if (preg_match('/^size=(\d+)([KMGT]?)$/i', $part, $sizeMatch)) { $size = (float) $sizeMatch[1]; $unit = strtoupper($sizeMatch[2] ?? 'G'); switch ($unit) { case 'K': return $size / (1024 * 1024); case 'M': return $size / 1024; case 'G': case '': return $size; case 'T': return $size * 1024; } } } return 0; } /** * Extract size from unused disk by querying Proxmox storage content. * Matches the Storage page parseUnusedDiskString logic. */ protected function extractUnusedDiskSize(string $value): float { // Unused disks typically just have the volume reference // Example: cephpool:vm-100-disk-1 $volume = trim($value); // Extract storage name from volume (format: storage:volume-name) $storageName = 'cephpool'; if (str_contains($volume, ':')) { [$storageName, ] = explode(':', $volume, 2); } try { $proxmox = new Proxmox; $storageContent = $proxmox->request( '/nodes/' . $this->server->node->name . '/storage/' . $storageName . '/content', data: ['vmid' => $this->server->vm_id] ); if (isset($storageContent['data']) && is_array($storageContent['data'])) { foreach ($storageContent['data'] as $item) { if (isset($item['volid']) && $item['volid'] === $volume) { // Size is in bytes if (isset($item['size'])) { return round($item['size'] / (1024 * 1024 * 1024), 2); } break; } } } } catch (\Throwable $e) { // If we can't fetch size, return 0 } return 0; } /** * Refresh the cached VM status from Proxmox. */ public function loadVmStatus(): void { try { $proxmox = new Proxmox; $data = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current' )->json()['data'] ?? null; $this->status = $data ? (object) $data : null; } catch (\Throwable $e) { $this->status = null; } } }