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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 21:09:13 +02:00
co-authored by Claude Opus 5.5
parent 2c6e221db5
commit 862e021f2d
6 changed files with 438 additions and 27 deletions
+56
View File
@@ -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<array{key: string, dockerName: string, service: string, mountPaths: list<string>}>
*/
#[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.
*/
+94
View File
@@ -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<array-key, mixed> $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<array{key: string, dockerName: string, service: string, mountPaths: list<string>}>
*/
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
+10 -15
View File
@@ -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 {
+11 -3
View File
@@ -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;
}
@@ -467,4 +467,57 @@
@endif
</x-application.settings-section>
@endif
@if (count($externalVolumes) > 0)
{{-- External Compose volumes: read-only, no storage entry, never removed by Coolify --}}
<x-application.settings-section :id="'external-volumes-'.$resource->uuid" title="External volumes"
:flush="true"
helper="Docker volumes that the Docker Compose file declares with external: true. Coolify mounts them as written.">
<div class="flex w-full flex-col">
<div
class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-neutral-500 dark:border-white/[0.08] dark:text-fg-dim">
Managed outside Coolify. Coolify never removes these volumes.
</div>
<div class="data-table w-full">
<div class="data-table-header external-volumes-table-grid">
<span>Volume</span>
<span>Docker volume</span>
<span>Mount path</span>
<span>Service</span>
</div>
@foreach ($externalVolumes as $externalVolume)
<div class="env-table-item"
wire:key="external-volume-{{ $externalVolume['service'] }}-{{ $externalVolume['key'] }}">
<div
class="data-table-row external-volumes-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume</span>
<span class="block min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
title="{{ $externalVolume['key'] }}">{{ $externalVolume['key'] }}</span>
<span class="block text-xs text-neutral-500 dark:text-fg-dim">External volume</span>
</div>
<div class="min-w-0">
<span class="volumes-mobile-label volumes-field-label">Docker volume</span>
<span class="block min-w-0 truncate text-neutral-950 dark:text-fg"
title="{{ $externalVolume['dockerName'] }}">{{ $externalVolume['dockerName'] }}</span>
</div>
<div class="min-w-0">
<span class="volumes-mobile-label volumes-field-label">Mount path</span>
@foreach ($externalVolume['mountPaths'] as $mountPath)
<span class="block min-w-0 truncate text-neutral-950 dark:text-fg"
title="{{ $mountPath }}">{{ $mountPath }}</span>
@endforeach
</div>
<div class="min-w-0">
<span class="volumes-mobile-label volumes-field-label">Service</span>
<span class="block min-w-0 truncate"
title="{{ $externalVolume['service'] }}">{{ $externalVolume['service'] }}</span>
</div>
</div>
</div>
@endforeach
</div>
</div>
</x-application.settings-section>
@endif
</div>
+214 -9
View File
@@ -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 = '/<section[^>]*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('<input');
});
it('lists the external volumes of a service resource as read-only', function () {
$service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE);
serviceParser($service);
$serviceApplication = $service->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();
});
});