From 61b74aa0a1ce255a4c9576eb135a9d8f30ca2baf Mon Sep 17 00:00:00 2001 From: "Joseph.Rawlings" Date: Fri, 5 Dec 2025 06:47:33 -0500 Subject: [PATCH] feat: implement storage quota checks and VM state validation for drive management --- .../Servers/Proxmox/Livewire/Component.php | 153 ++++++++++++++++++ .../Servers/Proxmox/views/storage.blade.php | 148 ++++++++++++----- 2 files changed, 265 insertions(+), 36 deletions(-) diff --git a/extensions/Servers/Proxmox/Livewire/Component.php b/extensions/Servers/Proxmox/Livewire/Component.php index 6fc450c5e..3707cf448 100644 --- a/extensions/Servers/Proxmox/Livewire/Component.php +++ b/extensions/Servers/Proxmox/Livewire/Component.php @@ -2,6 +2,7 @@ namespace Paymenter\Extensions\Servers\Proxmox\Livewire; +use App\Helpers\ExtensionHelper; use App\Livewire\Component as BaseComponent; use Paymenter\Extensions\Servers\Proxmox\Models\Server; use Paymenter\Extensions\Servers\Proxmox\Proxmox; @@ -47,6 +48,15 @@ class Component extends BaseComponent 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( @@ -71,6 +81,149 @@ class Component extends BaseComponent } } + /** + * 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). + */ + 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). + */ + protected function isExcludedDisk(string $value): bool + { + $excludePatterns = ['cloudinit', 'efidisk', 'tpmstate']; + foreach ($excludePatterns as $pattern) { + if (str_contains(strtolower($value), $pattern)) { + return true; + } + } + return false; + } + + /** + * Extract disk size in GB from a disk configuration string. + */ + protected function extractDiskSize(string $value): float + { + // Example: cephpool:vm-100-disk-0,cache=writeback,size=50G + if (preg_match('/size=(\d+)([GMTK])?/i', $value, $matches)) { + $size = (float) $matches[1]; + $unit = strtoupper($matches[2] ?? 'G'); + + return match ($unit) { + 'T' => $size * 1024, + 'G' => $size, + 'M' => $size / 1024, + 'K' => $size / 1024 / 1024, + default => $size, + }; + } + return 0; + } + + /** + * Extract size from unused disk by querying Proxmox. + */ + protected function extractUnusedDiskSize(string $value): float + { + // For unused disks, we need to query the volume info + // Format: storage:volume (e.g., cephpool:vm-100-disk-1) + try { + if (!preg_match('/^([^:]+):(.+)$/', $value, $matches)) { + return 0; + } + + $storage = $matches[1]; + $volume = $matches[2]; + + $proxmox = new Proxmox; + $response = $proxmox->request( + '/nodes/' . $this->server->node->name . '/storage/' . $storage . '/content/' . $storage . ':' . $volume + ); + + if ($response->successful()) { + $data = $response->json()['data'] ?? []; + $sizeBytes = $data['size'] ?? 0; + return $sizeBytes / 1024 / 1024 / 1024; // Convert to GB + } + } catch (\Throwable $e) { + // Ignore errors, return 0 + } + + return 0; + } + /** * Refresh the cached VM status from Proxmox. */ diff --git a/extensions/Servers/Proxmox/views/storage.blade.php b/extensions/Servers/Proxmox/views/storage.blade.php index 0a97ea791..f52e775ac 100644 --- a/extensions/Servers/Proxmox/views/storage.blade.php +++ b/extensions/Servers/Proxmox/views/storage.blade.php @@ -32,6 +32,10 @@ } + @php + $vmRunning = $status && $status->status !== 'stopped'; + @endphp +
@@ -96,6 +100,21 @@

Manage virtual hard disks attached to your VPS. Add, configure, or remove storage drives.

+ + @if($status && $status->status !== 'stopped') +
+
+
+ +
+
+

Server Must Be Powered Off

+

Storage changes can only be made when the server is in a powered off state. Please shut down your server before making any storage modifications.

+
+
+
+ @endif +
@@ -114,12 +133,19 @@ Refresh - @if($this->remainingStorage > 0) + @if($this->remainingStorage > 0 && !$vmRunning) + @elseif($this->remainingStorage > 0 && $vmRunning) + @endif
@@ -211,14 +237,14 @@
- @if($status && $status->status !== 'stopped' && !$this->scsiControllerSynced) + @if($vmRunning && !$this->scsiControllerSynced)
VM must be stopped to change the SCSI controller type. @@ -237,10 +263,12 @@

No drives attached to this VM.

- @if($this->remainingStorage > 0) + @if($this->remainingStorage > 0 && !$vmRunning) + @elseif($this->remainingStorage > 0 && $vmRunning) +

Power off server to add drives

@endif
@else @@ -275,37 +303,64 @@
- - - @if(count($unusedDrives) >= 2) + @if($vmRunning) + + + @else - + + @if(count($unusedDrives) >= 2) + + @else + + @endif + @endif -
@@ -372,28 +427,49 @@
- @if($totalAllocatedStorage >= $maxAllowedStorage && $maxAllowedStorage > 0) + @if($vmRunning) + + + @elseif($totalAllocatedStorage >= $maxAllowedStorage && $maxAllowedStorage > 0) + @else + @endif -