From f5f904a403a7c490dd6d72b812a4ceaf289ab412 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:01:36 +0200 Subject: [PATCH] refactor(storage): separate volumes from directory mounts --- app/Livewire/Project/Service/Storage.php | 15 +++++-- app/Livewire/Project/Shared/Storages/All.php | 19 ++++++++ app/Livewire/Project/Shared/Storages/Show.php | 9 ---- .../project/service/storage.blade.php | 16 ------- .../project/shared/storages/all.blade.php | 16 ++++++- .../PersistentStorageVolumesLayoutTest.php | 45 +++++++++++++++++++ 6 files changed, 90 insertions(+), 30 deletions(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b6..bb8a39d2b1 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,14 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + return str($this->resource->name ?? 'volume') + ->slug() + ->append('-data') + ->value(); + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a4..efe54a6a7d 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ class All extends Component $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 8acdbcb6e4..7e1e2dec1d 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -197,13 +197,4 @@ class Show extends Component return true; } - - public function clearHostPath() - { - $this->authorize('update', $this->resource); - $this->hostPath = null; - $this->storage->host_path = null; - $this->storage->save(); - $this->dispatch('success', 'Source path removed. Use Directory Mount for host directory bindings.'); - } } diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 81c19bd3f0..42ade3da6e 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@

Mount a Docker volume inside the container.

- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 25a4fd7492..e78cb70727 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,7 +154,21 @@
Source Path - + @if (filled($form['hostPath'])) +
+
+ +
+ +
+ @else + - + @endif
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index b22d998914..b6fd9dd74a 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -33,6 +33,7 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); }); +use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; @@ -206,6 +207,50 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); }); +it('keeps bind mount source paths out of the add volume form', function () { + $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); + + expect($storageView) + ->not->toContain('id="host_path"') + ->not->toContain('Swarm Mode detected'); +}); + +it('creates named volumes without a host path in swarm mode', function () { + [$application] = createApplicationWithVolume(); + $application->persistentStorages()->delete(); + + Livewire::test(Storage::class, ['resource' => $application]) + ->set('isSwarm', true) + ->set('name', 'storage-app-data') + ->set('mount_path', '/data') + ->call('submitPersistentVolume') + ->assertHasNoErrors(); + + expect($application->persistentStorages()->first()) + ->name->toBe($application->uuid.'-storage-app-data') + ->host_path->toBeNull(); +}); + +it('uses a resource based default name for new volumes', function () { + [$application] = createApplicationWithVolume(['name' => 'Storage App']); + + Livewire::test(Storage::class, ['resource' => $application]) + ->assertSet('name', 'storage-app-data'); +}); + +it('removes existing bind mount source paths from the volume table', function () { + [$application, $volume] = createApplicationWithVolume(volumeAttributes: [ + 'host_path' => '/srv/storage', + ]); + + Livewire::test(All::class, ['resource' => $application]) + ->assertSet("forms.{$volume->id}.hostPath", '/srv/storage') + ->call('clearHostPath', $volume->id) + ->assertHasNoErrors(); + + expect($volume->refresh()->host_path)->toBeNull(); +}); + it('creates and exposes volume backups for service storage', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id,