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(); + }); +});