diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 3bf5001f4d..470147a73d 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -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 = ''; diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 3dadfb46f4..ce564829fb 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -22,7 +22,7 @@ class All extends Component /** * Editable form state keyed by storage id. * - * @var array + * @var array */ 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', ]); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 0454d97049..a3f9be2db5 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -189,11 +189,30 @@ class Proxy extends Component { try { $this->proxySettings = GetProxyConfiguration::run($this->server); + $this->clearAppliedTraefikBranchWarning(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function getTraefikVersionForWarningProperty(): ?string + { + if ($this->server->detected_traefik_version) { + return $this->server->detected_traefik_version; + } + + if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) { + return null; + } + + $configuration = $this->server->proxy->get('last_saved_proxy_configuration'); + if (! is_string($configuration) || ! preg_match('/^\s*image:\s*[\'\"]?traefik:(v?\d+\.\d+(?:\.\d+)?|latest)[\'\"]?\s*$/mi', $configuration, $matches)) { + return null; + } + + return $matches[1]; + } + /** * Get the latest Traefik version for this server's current branch. * @@ -211,7 +230,7 @@ class Proxy extends Component } // Get this server's current version - $currentVersion = $this->server->detected_traefik_version; + $currentVersion = $this->traefikVersionForWarning; // If we have a current version, try to find matching branch if ($currentVersion && $currentVersion !== 'latest') { @@ -244,7 +263,7 @@ class Proxy extends Component return false; } - $currentVersion = $this->server->detected_traefik_version; + $currentVersion = $this->traefikVersionForWarning; if (! $currentVersion || $currentVersion === 'latest') { return false; } @@ -273,7 +292,7 @@ class Proxy extends Component } // Get this server's current version - $currentVersion = $this->server->detected_traefik_version; + $currentVersion = $this->traefikVersionForWarning; if (! $currentVersion || $currentVersion === 'latest') { return null; } @@ -335,6 +354,14 @@ class Proxy extends Component } } + public function getLatestNewerTraefikVersionProperty(): ?string + { + $branch = $this->newerTraefikBranchAvailable; + $version = $branch ? ($this->getTraefikVersions()[$branch] ?? null) : null; + + return $version ? 'v'.ltrim($version, 'v') : null; + } + private function getConfiguredTraefikBranch(): ?string { if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) { diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 184aa01eb3..b974e03db3 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -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}", diff --git a/resources/css/app.css b/resources/css/app.css index ac7c4ab041..989ac99217 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 { diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index c5e0953ac9..e731c7f2ee 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -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())
-
- Volume Name +
+ Storage Name + @if ($hasSourcePaths) + Source Path + @endif Destination Path @if ($supportsPreviewSuffix)
@@ -47,16 +51,30 @@ @if ($inputsReadonly)
-
+
- Volume Name + Storage Name
{{ $form['name'] }}
+ @if (blank($storage->host_path)) + Volume mount + @endif
+ @if ($hasSourcePaths) +
+ Source Path + @if (filled($storage->host_path)) + + @else + - + @endif +
+ @endif +
Destination Path @else
-
+
- Volume Name + Storage Name
+ @if (blank($storage->host_path)) + Volume mount + @endif
+ @if ($hasSourcePaths) +
+ Source Path + @if (filled($storage->host_path)) + + @else + - + @endif +
+ @endif +
Destination Path @if ($server->proxyType() === ProxyTypes::TRAEFIK->value) - @if ($server->detected_traefik_version === 'latest') + @if ($this->traefikVersionForWarning === 'latest') The proxy uses the latest tag. Pin traefik:{{ $this->latestTraefikVersion }} for predictable updates. - @elseif($this->isTraefikOutdated) + @endif + @if ($this->isTraefikOutdated) - Version {{ $this->latestTraefikVersion }} is available. Test the update before - applying it to production servers. + {{ $server->detected_traefik_version ? 'Running version' : 'Configured image' }} + v{{ ltrim($this->traefikVersionForWarning, 'v') }}. The latest patch + for this branch is {{ $this->latestTraefikVersion }}. Test the update before applying it + to production servers. - @elseif($this->newerTraefikBranchAvailable) + @endif + @if ($this->newerTraefikBranchAvailable) - {{ $this->newerTraefikBranchAvailable }} is available. Review the Traefik - changelog for breaking changes before upgrading. + {{ $this->newerTraefikBranchAvailable }} is available (latest patch: + {{ $this->latestNewerTraefikVersion }}). Review the Traefik changelog for breaking + changes before upgrading. @endif @endif diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 77778537f3..c2d4678cbe 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -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('/]*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, '')) + ->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 () { diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index de3cf8e17c..c3388e3a02 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -7,13 +7,94 @@ use App\Jobs\CheckTraefikVersionJob; use App\Livewire\Server\Proxy; use App\Models\Server; use App\Models\Team; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; +use Livewire\Livewire; uses(RefreshDatabase::class); +it('shows patch and minor upgrade warnings on the first proxy render', function () { + Cache::put('coolify:versions:all', [ + 'traefik' => [ + 'v3.7' => '3.7.13', + 'v3.6' => '3.6.25', + ], + ]); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + $this->actingAs($user); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'], + 'detected_traefik_version' => '3.6.1', + 'traefik_outdated_info' => [ + 'current' => '3.6.1', + 'latest' => '3.7.13', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + Livewire::test(Proxy::class, ['server' => $server]) + ->assertSee('Traefik patch update available') + ->assertSee('v3.6.25') + ->assertSee('New Traefik minor version available') + ->assertSee('v3.7.13'); +}); + +it('shows a warning from the saved image before version detection finishes', function () { + Cache::put('coolify:versions:all', [ + 'traefik' => ['v3.7' => '3.7.13', 'v3.6' => '3.6.25'], + ]); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + $this->actingAs($user); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + 'last_saved_proxy_configuration' => "services:\n traefik:\n image: 'traefik:v3.6.5'", + ], + 'detected_traefik_version' => null, + ]); + + Livewire::test(Proxy::class, ['server' => $server]) + ->assertSee('Configured image') + ->assertSee('v3.6.5') + ->assertSee('v3.6.25') + ->assertSee('v3.7.13'); +}); + +it('does not treat an unapplied image as the running Traefik version', function () { + $server = Server::factory()->make([ + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + 'last_saved_settings' => 'new', + 'last_applied_settings' => 'old', + 'last_saved_proxy_configuration' => "services:\n traefik:\n image: traefik:v3.6.5", + ], + 'detected_traefik_version' => null, + ]); + + $component = new Proxy; + $component->server = $server; + + expect($component->getTraefikVersionForWarningProperty())->toBeNull(); +}); + it('ignores stale minor upgrade information for the detected Traefik version', function () { Cache::put('coolify:versions:all', [ 'traefik' => [ @@ -120,6 +201,7 @@ YAML, $component = new Proxy; $component->server = $server; $component->mount(); + $component->loadProxyConfiguration(); expect($server->refresh()->traefik_outdated_info)->toBeNull(); });