refactor(storage): separate volumes from directory mounts

This commit is contained in:
Andras Bacsai
2026-08-18 20:01:36 +02:00
parent e62a2d45a0
commit f5f904a403
6 changed files with 90 additions and 30 deletions
+11 -4
View File
@@ -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();
@@ -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.
*/
@@ -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.');
}
}
@@ -116,25 +116,9 @@
<p class="text-[13px] leading-5 text-neutral-500 dark:text-fg-dim">
Mount a Docker volume inside the container.
</p>
@if ($isSwarm)
<div class="text-warning">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.</div>
@endif
<div class="flex flex-col gap-4">
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
id="name" label="Name" required helper="Volume name." />
@if ($isSwarm)
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path" required
helper="Directory on the host system." />
@else
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path"
helper="Directory on the host system." />
@endif
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/tmp/root" id="mount_path" label="Destination Path"
required helper="Directory inside the container." />
@@ -154,7 +154,21 @@
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<x-forms.input id="forms.{{ $id }}.hostPath" placeholder="Host path (optional)" />
@if (filled($form['hostPath']))
<div class="flex items-center gap-1.5">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.hostPath" />
</div>
<x-modal-confirmation title="Remove Source Path?" isErrorButton
buttonTitle="Remove" submitAction="clearHostPath({{ $id }})"
:actions="[
'Are you sure you want to remove the source path?',
'Use a Directory Mount when you need to mount a host directory.',
]" />
</div>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
<div class="volumes-cell-dest min-w-0">
@@ -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,