From 862e021f2d3e113db6c87a5f226e533209ce258a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:09:13 +0200 Subject: [PATCH] feat(compose): warn about legacy external volumes and list external volumes Legacy Compose applications keep the old name of an external volume. The deployment log now tells the user why and how to move to the external volume. The storage page shows external volumes as a read-only list. Co-Authored-By: Claude Opus 5.5 --- app/Livewire/Project/Service/Storage.php | 56 +++++ bootstrap/helpers/parsers.php | 94 ++++++++ bootstrap/helpers/shared.php | 25 +- resources/css/app.css | 14 +- .../project/service/storage.blade.php | 53 +++++ tests/Feature/ComposeExternalVolumeTest.php | 223 +++++++++++++++++- 6 files changed, 438 insertions(+), 27 deletions(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 470147a73d..785c5531df 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -6,8 +6,13 @@ use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; +use Livewire\Attributes\Locked; use Livewire\Component; class Storage extends Component @@ -44,6 +49,15 @@ class Storage extends Component public int $cachedDirectoryCount = 0; + /** + * External Compose volumes that this resource mounts as written. They have no storage entry, + * so the page shows them read-only (see loadExternalVolumes()). + * + * @var list}> + */ + #[Locked] + public array $externalVolumes = []; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -73,6 +87,7 @@ class Storage extends Component // Counts only on mount — child All (volumes) / file list load their own payloads. $this->loadVolumeCount(); $this->loadFileStorageMetaCounts(); + $this->loadExternalVolumes(); $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); @@ -93,6 +108,7 @@ class Storage extends Component $this->resource->unsetRelation('persistentStorages'); $this->loadVolumeCount(); $this->loadFileStorageMetaCounts(); + $this->loadExternalVolumes(); $this->loadFileStorageForActiveTab(); if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) { @@ -132,6 +148,46 @@ class Storage extends Component $this->cachedVolumeCount = $this->resource->persistentStorages()->count(); } + /** + * Loads the external volumes that the current Compose file of the resource declares and that the + * resource mounts as written. Coolify does not manage these volumes, so they have no storage entry. + * + * Not included: + * - an external volume for which the resource still has the old storage entry with the generated + * name. The parsers keep that old volume, and the storage list shows the entry with a note. + * - the external volumes of a legacy Compose application (parser version 1 or 2). These parsers + * keep the old volume names, and the deployment log shows a warning. + */ + private function loadExternalVolumes(): void + { + $this->externalVolumes = []; + if (! Gate::allows('view', $this->resource)) { + return; + } + + if ($this->resource instanceof Application) { + if ($this->resource->build_pack !== 'dockercompose' || (int) $this->resource->compose_parsing_version < 3) { + return; + } + $compose = $this->resource->docker_compose_raw; + $uuid = $this->resource->uuid; + $serviceName = null; + } elseif ($this->resource instanceof ServiceApplication || $this->resource instanceof ServiceDatabase) { + $compose = $this->resource->service?->docker_compose_raw; + $uuid = $this->resource->service?->uuid; + $serviceName = $this->resource->name; + } else { + return; + } + + $storageNames = $this->resource->persistentStorages()->pluck('name')->all(); + $this->externalVolumes = collect(composeExternalVolumeMounts($compose)) + ->when($serviceName !== null, fn ($mounts) => $mounts->where('service', $serviceName)) + ->reject(fn (array $mount): bool => in_array("{$uuid}_".Str::slug($mount['key'], '-'), $storageNames, true)) + ->values() + ->all(); + } + /** * Counts only — avoids loading file contents into the Livewire snapshot on the volumes tab. */ diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 907d97bc89..7187cd639d 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -402,6 +402,36 @@ function legacyApplicationComposeVolumeName(Application $resource, string $sourc return $resource->compose_parsing_version === '2' ? "{$resource->uuid}-{$name}" : $name; } +/** + * Records a warning when a legacy Compose application (parser version 1 or 2) does not use an + * external volume as written. These parsers keep the old volume name (see + * legacyApplicationComposeVolumeName()), so the resource keeps its data. The parser version 1 keeps + * the name of a production volume, so it uses the external volume and gets no warning. + * + * The volume names are not validated here, so that the legacy parsers keep their old behavior. The + * warning shows the Docker volume name only when it is a literal, valid Docker volume name. + * + * @param iterable $topLevelVolumes the top-level volumes as declared in the Compose file + */ +function warnLegacyApplicationComposeExternalVolume(Application $resource, iterable $topLevelVolumes, string $source, int $pull_request_id): void +{ + $declaration = collect($topLevelVolumes)->get($source); + if (! isComposeExternalVolume($declaration)) { + return; + } + $keptName = legacyApplicationComposeVolumeName($resource, $source, $pull_request_id); + if ($keptName === $source) { + return; + } + + $dockerVolume = data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $source; + $external = ! is_string($dockerVolume) || $dockerVolume === $source || preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $dockerVolume) !== 1 + ? 'external' + : "external (Docker volume '{$dockerVolume}')"; + + $resource->addComposeVolumeWarning("Volume '{$source}' is declared as {$external}, but this application uses an old Compose parser, so Coolify uses '{$keptName}'. To use the external volume, copy your data into it, then clone this application or create it again."); +} + /** * The warning for an external volume that the resource does not use yet (see useComposeExternalVolumeAsWritten()). * The volume names are validated and contain no secrets. @@ -454,6 +484,70 @@ function composeExternalVolumeDockerNames(?string $compose): array ->all(); } +/** + * The external volumes that the services of a Compose file mount, one entry for each volume and + * service. Returns an empty list for a Compose file that is empty or not valid YAML. + * + * @return list}> + */ +function composeExternalVolumeMounts(?string $compose): array +{ + if (blank($compose)) { + return []; + } + try { + $yaml = Yaml::parse($compose); + } catch (Throwable) { + return []; + } + $topLevelVolumes = data_get($yaml, 'volumes'); + $services = data_get($yaml, 'services'); + if (! is_array($topLevelVolumes) || ! is_array($services)) { + return []; + } + + $mounts = []; + foreach ($services as $serviceName => $service) { + $serviceVolumes = data_get($service, 'volumes'); + if (! is_array($serviceVolumes)) { + continue; + } + foreach ($serviceVolumes as $volume) { + $parsed = match (true) { + is_string($volume) => parseDockerVolumeString($volume), + is_array($volume) => $volume, + default => [], + }; + // parseDockerVolumeString() returns Stringable values. + $source = data_get($parsed, 'source'); + $target = data_get($parsed, 'target'); + if (! (is_scalar($source) || $source instanceof Stringable) || ! (is_scalar($target) || $target instanceof Stringable)) { + continue; + } + $source = (string) $source; + $target = (string) $target; + $declaration = $topLevelVolumes[$source] ?? null; + if (! isComposeExternalVolume($declaration)) { + continue; + } + + $dockerName = data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $source; + $mountKey = $source."\0".$serviceName; + $mounts[$mountKey] ??= [ + 'key' => $source, + 'dockerName' => is_scalar($dockerName) ? (string) $dockerName : $source, + 'service' => (string) $serviceName, + 'mountPaths' => [], + ]; + if (! in_array($target, $mounts[$mountKey]['mountPaths'], true)) { + $mounts[$mountKey]['mountPaths'][] = $target; + } + } + } + + return array_values($mounts); +} + /** * The key and the name of an external volume must be literal Docker volume names. Docker Compose * can resolve variables in `name:`, but then only the deployment `.env` sets which existing volume diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7f882bcaab..b12a1e9a38 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3393,8 +3393,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $server = $resource->destination->server; $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); // Legacy Compose applications (parser versions 1 and 2) never stored their volumes, so Coolify - // cannot tell which external volume already holds data. They keep the old volume names. - $externalTopLevelVolumes = collect([]); + // cannot tell which external volume already holds data. They keep the old volume names, and + // warnLegacyApplicationComposeExternalVolume() tells the user about it. + $declaredTopLevelVolumes = collect($topLevelVolumes->all()); if ($pull_request_id !== 0) { $topLevelVolumes = collect([]); } @@ -3427,7 +3428,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0) { $definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $externalTopLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $declaredTopLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) { $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3466,7 +3467,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $containerName = "$serviceName-$baseName"; if ($resource->compose_parsing_version === '1') { if (count($serviceVolumes) > 0) { - $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $externalTopLevelVolumes, $pull_request_id) { + $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $declaredTopLevelVolumes, $pull_request_id) { if (is_string($volume)) { $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { @@ -3484,9 +3485,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); - } elseif (useComposeExternalVolumeAsWritten($resource, $resource, $externalTopLevelVolumes, $name->value(), legacyApplicationComposeVolumeName($resource, $name->value(), $pull_request_id))) { - // An external volume is used as written. } else { + warnLegacyApplicationComposeExternalVolume($resource, $declaredTopLevelVolumes, $name->value(), $pull_request_id); if ($pull_request_id !== 0) { $name = addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); @@ -3553,10 +3553,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { data_set($volume, 'source', $source.':'.$target); } - } elseif (useComposeExternalVolumeAsWritten($resource, $resource, $externalTopLevelVolumes, (string) $source, legacyApplicationComposeVolumeName($resource, (string) $source, $pull_request_id))) { - // An external volume is used as written. - data_set($volume, 'source', $source.':'.$target.($read_only ? ':ro' : '')); } else { + warnLegacyApplicationComposeExternalVolume($resource, $declaredTopLevelVolumes, (string) $source, $pull_request_id); if ($pull_request_id !== 0) { $source = addPreviewDeploymentSuffix($source, $pull_request_id); } @@ -3595,7 +3593,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } elseif ($resource->compose_parsing_version === '2') { if (count($serviceVolumes) > 0) { - $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $externalTopLevelVolumes, $pull_request_id) { + $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $declaredTopLevelVolumes, $pull_request_id) { if (is_string($volume)) { $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { @@ -3613,9 +3611,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); - } elseif (useComposeExternalVolumeAsWritten($resource, $resource, $externalTopLevelVolumes, $name->value(), legacyApplicationComposeVolumeName($resource, $name->value(), $pull_request_id))) { - // An external volume is used as written. } else { + warnLegacyApplicationComposeExternalVolume($resource, $declaredTopLevelVolumes, $name->value(), $pull_request_id); if ($pull_request_id !== 0) { $uuid = $resource->uuid; $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id); @@ -3684,10 +3681,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { data_set($volume, 'source', $source.':'.$target); } - } elseif (useComposeExternalVolumeAsWritten($resource, $resource, $externalTopLevelVolumes, (string) $source, legacyApplicationComposeVolumeName($resource, (string) $source, $pull_request_id))) { - // An external volume is used as written. - data_set($volume, 'source', $source.':'.$target.($read_only ? ':ro' : '')); } else { + warnLegacyApplicationComposeExternalVolume($resource, $declaredTopLevelVolumes, (string) $source, $pull_request_id); if ($pull_request_id === 0) { $source = $uuid."-$source"; } else { diff --git a/resources/css/app.css b/resources/css/app.css index 989ac99217..3fc162101d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3025,6 +3025,11 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } +/* External Compose volumes (read-only): Volume | Docker volume | Mount path | Service */ +.external-volumes-table-grid { + grid-template-columns: minmax(8rem, 1fr) minmax(9rem, 1.2fr) minmax(8rem, 1fr) minmax(6rem, 0.8fr); +} + /* * Same tokens as .application-settings-form label (13px / medium / subtle). * Do not use text-sm (14px) — settings labels override Tailwind to 13px. @@ -3056,13 +3061,15 @@ input[type="search"]::-webkit-search-results-decoration { @media (max-width: 768px) { .data-table-header.volumes-table-grid, .data-table-header.volumes-table-grid-with-pr, - .data-table-header.volumes-table-grid-readonly { + .data-table-header.volumes-table-grid-readonly, + .data-table-header.external-volumes-table-grid { display: none; } .data-table-row.volumes-table-grid, .data-table-row.volumes-table-grid-with-pr, - .data-table-row.volumes-table-grid-readonly { + .data-table-row.volumes-table-grid-readonly, + .data-table-row.external-volumes-table-grid { display: flex; flex-direction: column; align-items: stretch; @@ -3081,7 +3088,8 @@ input[type="search"]::-webkit-search-results-decoration { .data-table-row.volumes-table-grid > *, .data-table-row.volumes-table-grid-with-pr > *, - .data-table-row.volumes-table-grid-readonly > * { + .data-table-row.volumes-table-grid-readonly > *, + .data-table-row.external-volumes-table-grid > * { width: 100%; min-width: 0; } diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index acf01c9418..c4b5a35f02 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -467,4 +467,57 @@ @endif @endif + + @if (count($externalVolumes) > 0) + {{-- External Compose volumes: read-only, no storage entry, never removed by Coolify --}} + +
+
+ Managed outside Coolify. Coolify never removes these volumes. +
+
+
+ Volume + Docker volume + Mount path + Service +
+ @foreach ($externalVolumes as $externalVolume) +
+
+
+ Volume + {{ $externalVolume['key'] }} + External volume +
+
+ Docker volume + {{ $externalVolume['dockerName'] }} +
+
+ Mount path + @foreach ($externalVolume['mountPaths'] as $mountPath) + {{ $mountPath }} + @endforeach +
+
+ Service + {{ $externalVolume['service'] }} +
+
+
+ @endforeach +
+
+
+ @endif diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index 3a50b4cfcf..54807046d0 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -4,6 +4,7 @@ use App\Actions\Service\DeployServiceApplication; use App\Actions\Service\StartService; use App\Jobs\ApplicationDeploymentJob; use App\Jobs\DeleteResourceJob; +use App\Livewire\Project\Service\Storage as StoragePage; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; @@ -195,6 +196,23 @@ function legacyExternalVolumeWarning(string $oldName, ?string $dockerVolume = nu return "Volume 'shared-data' is declared as {$external}, but Coolify still uses '{$oldName}' because this resource used it before. To use the external volume, copy your data into it and delete the storage entry '{$oldName}', then redeploy."; } +function legacyParserExternalVolumeWarning(string $keptName, ?string $dockerVolume = null): string +{ + $external = $dockerVolume === null ? 'external' : "external (Docker volume '{$dockerVolume}')"; + + return "Volume 'shared-data' is declared as {$external}, but this application uses an old Compose parser, so Coolify uses '{$keptName}'. To use the external volume, copy your data into it, then clone this application or create it again."; +} + +/** + * The "External volumes" card of the storage page, or an empty string when the page does not show it. + */ +function externalVolumesSection(string $html, string $resourceUuid): string +{ + $pattern = '/]*id="external-volumes-'.preg_quote($resourceUuid, '/').'".*?<\/section>/s'; + + return preg_match($pattern, $html, $matches) === 1 ? $matches[0] : ''; +} + /** * Runs the Compose parse step of a deployment job. * @@ -512,7 +530,7 @@ describe('legacy parsers', function () { ->and($service->composeVolumeWarnings())->toBe([]); }); - it('keeps the old volume names of a legacy application, because it has no storage entries to check', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume) { + it('keeps the old volume names of a legacy application and shows a warning when the name is not the external name', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume, ?string $dockerVolume) { // Legacy Compose applications (parser versions 1 and 2) never stored their volumes, so Coolify // cannot tell which external volume already holds data: they always keep the old names. $application = externalVolumeApplication($compose, $parsingVersion); @@ -526,16 +544,68 @@ describe('legacy parsers', function () { expect($parsed['services'][$serviceName]['volumes'])->toContain($expectedVolume) ->and(array_keys($parsed['volumes'] ?? []))->toContain($expectedName) - ->and($application->composeVolumeWarnings())->toBe([]); + ->and($application->composeVolumeWarnings())->toBe( + // Parser version 1 keeps the name of a production volume, so it uses the external volume. + $expectedName === 'shared-data' ? [] : [legacyParserExternalVolumeWarning($expectedName, $dockerVolume)] + ); })->with([ - 'v1 short' => ['1', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro'], - 'v1 short preview' => ['1', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data-pr-42:/data:ro'], - 'v1 long preview' => ['1', 42, EXTERNAL_VOLUME_LONG_COMPOSE, 'shared-data-pr-42:/data'], - 'v2 short' => ['2', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data:/data:ro'], - 'v2 short preview' => ['2', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data-pr-42:/data:ro'], - 'v2 long' => ['2', 0, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data:/data'], - 'v2 long preview' => ['2', 42, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data-pr-42:/data'], + 'v1 short' => ['1', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro', null], + 'v1 short preview' => ['1', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data-pr-42:/data:ro', null], + 'v1 long preview' => ['1', 42, EXTERNAL_VOLUME_LONG_COMPOSE, 'shared-data-pr-42:/data', 'existing-shared-volume'], + 'v2 short' => ['2', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data:/data:ro', null], + 'v2 short preview' => ['2', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data-pr-42:/data:ro', null], + 'v2 long' => ['2', 0, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data:/data', 'existing-shared-volume'], + 'v2 long preview' => ['2', 42, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data-pr-42:/data', 'existing-shared-volume'], ]); + + it('writes the warning of a legacy application to the deployment log', function (string $parsingVersion, int $pullRequestId, string $keptName) { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion); + $keptName = str_replace('{uuid}', $application->uuid, $keptName); + $preview = $pullRequestId === 0 ? null : externalVolumePreview($application, $pullRequestId); + + [$composeFile, $logEntries] = parseExternalVolumeDeployment($application, $pullRequestId, $preview); + + $serviceName = $pullRequestId === 0 ? 'web' : "web-pr-{$pullRequestId}"; + expect(data_get($composeFile, "services.{$serviceName}.volumes"))->toContain("{$keptName}:/data:ro") + ->and($logEntries)->toBe([['Warning: '.legacyParserExternalVolumeWarning($keptName), 'stderr']]); + })->with([ + 'v2 production' => ['2', 0, '{uuid}-shared-data'], + 'v2 preview' => ['2', 42, '{uuid}-shared-data-pr-42'], + 'v1 preview' => ['1', 42, 'shared-data-pr-42'], + ]); + + it('writes no warning to the deployment log for a production deployment of a parser version 1 application', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, '1'); + + [$composeFile, $logEntries] = parseExternalVolumeDeployment($application); + + expect(data_get($composeFile, 'services.web.volumes'))->toContain('shared-data:/data:ro') + ->and($logEntries)->toBe([]); + }); + + it('does not reject a variable in the external volume name of a legacy application', function () { + $compose = "services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n external: true\n name: \${SHARED_VOLUME}\n"; + $application = externalVolumeApplication($compose, '2'); + $uuid = $application->uuid; + + $parsed = parseDockerComposeFile($application)->toArray(); + + // The variable is not validated, so the warning does not show it. + expect($parsed['services']['web']['volumes'])->toContain("{$uuid}-shared-data:/data") + ->and($application->composeVolumeWarnings())->toBe([legacyParserExternalVolumeWarning("{$uuid}-shared-data")]); + }); + + it('uses the external volume as written after a legacy application is cloned', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, '2'); + loginExternalVolumeOwner(); + + $clone = clone_application($application, test()->destination, ['environment_id' => test()->environment->id])->fresh(); + + $parsed = $clone->parse()->toArray(); + expect($clone->compose_parsing_version)->toBe('5') + ->and($parsed['services']['web']['volumes'])->toContain('shared-data:/data:ro') + ->and($clone->composeVolumeWarnings())->toBe([]); + }); }); describe('validation', function () { @@ -782,3 +852,138 @@ describe('warnings', function () { expect($command)->not->toContain('declared as external'); }); }); + +describe('storage page', function () { + it('lists the external volumes of a Compose application as read-only', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE); + applicationParser($application); + loginExternalVolumeOwner(); + + $html = Livewire::test(StoragePage::class, ['resource' => $application->fresh()])->html(); + $section = externalVolumesSection($html, $application->uuid); + + expect($section)->toContain('External volumes') + ->toContain('Managed outside Coolify. Coolify never removes these volumes.') + ->toContain('shared-data') + ->toContain('existing-shared-volume') + ->toContain('/data') + ->toContain('web') + ->not->toContain('app-data') + ->not->toContain('delete(') + ->not->toContain('Delete') + ->not->toContain('Backup') + ->not->toContain('wire:submit') + ->not->toContain('applications()->where('name', 'web')->firstOrFail(); + loginExternalVolumeOwner(); + + $html = Livewire::test(StoragePage::class, ['resource' => $serviceApplication])->html(); + $section = externalVolumesSection($html, $serviceApplication->uuid); + + expect($section)->toContain('External volumes') + ->toContain('shared-data') + ->toContain('/data') + ->toContain('web') + ->not->toContain('app-data') + ->not->toContain('delete(') + ->not->toContain('Backup'); + }); + + it('shows each mount path of an external volume', function () { + $compose = "services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\n - type: volume\n source: shared-data\n target: /backup\n read_only: true\nvolumes:\n shared-data:\n external: true\n"; + $application = externalVolumeApplication($compose); + loginExternalVolumeOwner(); + + $component = Livewire::test(StoragePage::class, ['resource' => $application]); + + expect($component->get('externalVolumes'))->toBe([[ + 'key' => 'shared-data', + 'dockerName' => 'shared-data', + 'service' => 'web', + 'mountPaths' => ['/data', '/backup'], + ]]); + }); + + it('does not list an external volume while the old storage entry is kept', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + legacyExternalVolumeRow($application, "{$application->uuid}_shared-data"); + loginExternalVolumeOwner(); + + $html = Livewire::test(StoragePage::class, ['resource' => $application->fresh()])->html(); + + expect(externalVolumesSection($html, $application->uuid))->toBe(''); + }); + + it('does not list an external volume of a service resource while its old storage entry is kept', function () { + $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); + $serviceApplication = legacyExternalVolumeServiceResource($service, 'web'); + legacyExternalVolumeRow($serviceApplication, "{$service->uuid}_shared-data"); + loginExternalVolumeOwner(); + + $html = Livewire::test(StoragePage::class, ['resource' => $serviceApplication->fresh()])->html(); + + expect(externalVolumesSection($html, $serviceApplication->uuid))->toBe(''); + }); + + it('lists the external volume after the old storage entry is deleted', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + $oldVolume = legacyExternalVolumeRow($application, "{$application->uuid}_shared-data"); + loginExternalVolumeOwner(); + $component = Livewire::test(StoragePage::class, ['resource' => $application->fresh()]); + expect($component->get('externalVolumes'))->toBe([]); + + $oldVolume->delete(); + $component->dispatch('storageCountsChanged'); + + expect(collect($component->get('externalVolumes'))->pluck('key')->all())->toBe(['shared-data']); + }); + + it('does not list external volumes of a legacy Compose application', function (string $parsingVersion) { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion); + loginExternalVolumeOwner(); + + $html = Livewire::test(StoragePage::class, ['resource' => $application])->html(); + + expect(externalVolumesSection($html, $application->uuid))->toBe(''); + })->with(['1', '2']); + + it('lets a team member see the external volumes without actions', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + $team = test()->environment->project->team; + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + test()->withoutVite()->actingAs($member); + session(['currentTeam' => $team]); + + $section = externalVolumesSection(Livewire::test(StoragePage::class, ['resource' => $application])->html(), $application->uuid); + + expect($section)->toContain('shared-data') + ->not->toContain('delete(') + ->not->toContain('Backup'); + }); + + it('does not show external volumes to a user of another team', function () { + $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + test()->withoutVite()->actingAs($otherUser); + session(['currentTeam' => $otherTeam]); + + $component = Livewire::test(StoragePage::class, ['resource' => $application]); + + expect($component->get('externalVolumes'))->toBe([]) + ->and(externalVolumesSection($component->html(), $application->uuid))->toBe(''); + + test()->get(route('project.application.persistent-storage', [ + 'project_uuid' => test()->environment->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'application_uuid' => $application->uuid, + ]))->assertNotFound(); + }); +});