fix(storage): preserve and display legacy bind mount source paths

Show existing source paths as read-only fields and label bind mounts in deployment configuration. Remove the action that could convert them to named volumes.
This commit is contained in:
Andras Bacsai
2026-09-24 16:49:04 +02:00
parent 2e928d86d8
commit 70631d2a11
6 changed files with 163 additions and 54 deletions
+2 -8
View File
@@ -24,8 +24,6 @@ class Storage extends Component
public string $mount_path = '';
public ?string $host_path = null;
public string $file_storage_path = '';
public ?string $file_storage_content = null;
@@ -209,17 +207,14 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'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.',
]));
], ValidationPatterns::volumeNameMessages());
$name = $this->resource->uuid.'-'.$this->name;
LocalPersistentVolume::create([
'name' => $name,
'mount_path' => $this->mount_path,
'host_path' => $this->host_path,
'host_path' => null,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
@@ -347,7 +342,6 @@ class Storage extends Component
{
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
+2 -25
View File
@@ -22,7 +22,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
* @var array<int|string, array{name: string, mountPath: string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
*/
public array $forms = [];
@@ -65,6 +65,7 @@ class All extends Component
public function refreshList(): void
{
$this->authorize('view', $this->resource);
$this->resource->refresh();
$this->resource->unsetRelation('persistentStorages');
$this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]);
@@ -96,7 +97,6 @@ class All extends Component
$form = $this->forms[$storageId];
$storage->name = $form['name'];
$storage->mount_path = $form['mountPath'];
$storage->host_path = $form['hostPath'] ?: null;
$storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled'];
$storage->save();
@@ -108,25 +108,6 @@ 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.
*/
@@ -212,7 +193,6 @@ class All extends Component
$forms[$storage->id] = [
'name' => $storage->name,
'mountPath' => $storage->mount_path,
'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
'canDeleteStale' => $this->canUpdate
@@ -301,18 +281,15 @@ class All extends Component
$this->validate([
"forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(),
"forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean',
], array_merge(
ValidationPatterns::volumeNameMessages(),
[
"forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.',
"forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.',
]
), [
"forms.{$storageId}.name" => 'name',
"forms.{$storageId}.mountPath" => 'mount',
"forms.{$storageId}.hostPath" => 'host',
]);
}
@@ -237,7 +237,7 @@ class ApplicationConfigurationSnapshot
return $this->item(
key: 'volume_'.$volume->id,
label: 'Volume mount',
label: filled($volume->host_path) ? 'Directory mount' : 'Volume mount',
value: ['source' => $source, 'destination' => $volume->mount_path],
impact: 'redeploy',
displayValue: "{$source} → {$volume->mount_path}",
+14 -1
View File
@@ -2996,7 +2996,7 @@ input[type="search"]::-webkit-search-results-decoration {
background: color-mix(in srgb, var(--coollabs-base) 98%, white);
}
/* Persistent storage volumes: Name | Destination | [PR suffix] | Backup | [Actions] */
/* Persistent storage volumes: Name | [Source] | Destination | [PR suffix] | Backup | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem;
}
@@ -3009,6 +3009,18 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
}
.volumes-table-grid-readonly.has-source {
grid-template-columns: minmax(10rem, 1.2fr) minmax(10rem, 1.2fr) minmax(8rem, 1fr) 5rem;
}
.volumes-table-grid.has-source {
grid-template-columns: minmax(9rem, 1.1fr) minmax(14rem, 1.5fr) minmax(6rem, 1fr) 5rem 15rem;
}
.volumes-table-grid-with-pr.has-source {
grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 1.4fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
}
.volumes-mobile-label {
display: none;
}
@@ -3084,6 +3096,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-cell-name,
.volumes-cell-source,
.volumes-col-backup,
.volumes-cell-dest,
.volumes-cell-actions {
@@ -1,4 +1,5 @@
@php
$hasSourcePaths = $resource->persistentStorages->contains(fn ($storage) => filled($storage->host_path));
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
@@ -16,8 +17,11 @@
@if ($resource->persistentStorages->isNotEmpty())
<div class="data-table w-full">
<div class="data-table-header {{ $gridClass }}">
<span>Volume Name</span>
<div class="data-table-header {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }}">
<span>Storage Name</span>
@if ($hasSourcePaths)
<span>Source Path</span>
@endif
<span>Destination Path</span>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr flex items-center gap-1.5">
@@ -47,16 +51,30 @@
@if ($inputsReadonly)
<div class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="data-table-row {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<span class="volumes-mobile-label volumes-field-label">Storage Name</span>
<div class="flex min-w-0 items-center gap-2">
<span
class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
</div>
@if (blank($storage->host_path))
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
@endif
</div>
@if ($hasSourcePaths)
<div class="volumes-cell-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
@if (filled($storage->host_path))
<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@endif
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<span
@@ -143,16 +161,30 @@
</div>
@else
<form wire:submit="submit({{ $id }})" class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="data-table-row {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<span class="volumes-mobile-label volumes-field-label">Storage Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required />
</div>
</div>
@if (blank($storage->host_path))
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
@endif
</div>
@if ($hasSourcePaths)
<div class="volumes-cell-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
@if (filled($storage->host_path))
<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@endif
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="forms.{{ $id }}.mountPath" required
@@ -159,8 +159,7 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('data-table-header')
->toContain('volumes-table-grid')
->toContain('volumes-table-grid-readonly')
->toContain('Volume Name')
->not->toContain('Source Path')
->toContain('Storage Name')
->toContain('Destination Path')
->toContain('volumes-col-backup')
->toContain('supportsPreviewSuffix')
@@ -251,12 +250,10 @@ it('creates named Docker volumes without a source path in swarm mode', function
expect($application->persistentStorages()->first()->host_path)->toBeNull();
});
it('removes the source path column from Docker volume views', function () {
it('keeps source paths out of the named volume creation form', function () {
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($allView)
->not->toContain('Source Path')
->not->toContain('volumes-col-source')
->not->toContain('forms.{{ $id }}.hostPath')
->and(resource_path('views/livewire/project/shared/storages/show.blade.php'))
->not->toBeFile();
@@ -336,7 +333,7 @@ it('uses valid block wrappers around PR suffix helpers', function () {
->toBe(3);
});
it('keeps bind mount source paths out of the add volume form', function () {
it('shows legacy bind mount source paths without an unsafe removal action', function () {
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
$volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
@@ -344,9 +341,38 @@ it('keeps bind mount source paths out of the add volume form', function () {
->not->toContain('id="host_path"')
->not->toContain('Swarm Mode detected')
->and($volumesView)
->toMatch('/<x-modal-confirmation title="Remove Source Path\?"[^>]*canGate="update"[^>]*:canResource="\$resource"/')
->toContain('The next deployment will use a named Docker volume instead.')
->toContain('Data from the existing host directory will not be copied to the named volume.');
->not->toContain('submitAction="clearHostPath({{ $id }})"')
->not->toContain('The next deployment will use a named Docker volume instead.');
});
it('shows legacy source paths in read-only inputs beside the destination path', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect(substr_count($view, '<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />'))
->toBe(2)
->and(substr_count($view, 'class="volumes-cell-source'))
->toBe(2)
->and($view)->not->toContain('volumes-bind-details');
});
it('aligns bind and named volumes when they share the source path column', function () {
[$application] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
LocalPersistentVolume::create([
'name' => $application->uuid.'-cache',
'mount_path' => '/cache',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$document = new DOMDocument;
$previousState = libxml_use_internal_errors(true);
$document->loadHTML(Livewire::test(All::class, ['resource' => $application])->html());
libxml_clear_errors();
libxml_use_internal_errors($previousState);
$xpath = new DOMXPath($document);
expect($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' data-table-row ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]"))->toHaveCount(2)
->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]//span[contains(concat(' ', normalize-space(@class), ' '), ' data-table-cell-dash ')]"))->toHaveCount(1);
});
it('creates named volumes without a host path in swarm mode', function () {
@@ -379,17 +405,84 @@ it('uses a valid fallback default volume name when the resource name has no slug
->assertSet('name', 'volume-data');
});
it('removes existing bind mount source paths from the volume table', function () {
it('preserves existing bind mount source paths in 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)
->assertDontSee('Directory mount')
->assertSee('/srv/storage')
->assertDontSee('Remove Source Path');
expect($volume->refresh()->host_path)->toBe('/srv/storage')
->and(method_exists(All::class, 'clearHostPath'))->toBeFalse();
});
it('does not show a source path or removal action for a named volume', function () {
[$application] = createApplicationWithVolume();
Livewire::test(All::class, ['resource' => $application])
->assertDontSee('Directory mount')
->assertSee('Volume mount')
->assertDontSee('Remove Source Path');
});
it('keeps bind mount source paths out of editable form state', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('forms')[$volume->id])->not->toHaveKey('hostPath');
$component
->call('submit', $volume->id)
->assertHasNoErrors();
expect($volume->refresh()->host_path)->toBeNull();
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('does not offer bind mount conversion to a team member', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(All::class, ['resource' => $application])
->assertDontSee('Directory mount')
->assertSee('/srv/storage')
->assertDontSee('Remove Source Path');
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('does not show a bind mount source path to another team', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
Livewire::test(All::class, ['resource' => $application])
->assertForbidden();
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('labels bind mounts as directories in deployment configuration', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items'));
expect($storage->firstWhere('key', 'volume_'.$volume->id))
->toMatchArray(['label' => 'Directory mount', 'display_value' => '/srv/storage → /data']);
$volume->update(['host_path' => null]);
$storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items'));
expect($storage->firstWhere('key', 'volume_'.$volume->id)['label'])->toBe('Volume mount');
});
it('creates and exposes volume backups for service storage', function () {