fix(compose): use external volumes as declared

Compose volumes declared with external: true (or external: {name: x})
were renamed to <uuid>_<name>, so containers got a new empty volume
instead of the existing one. All Compose parsers (applications,
previews, services, and the legacy parsers) now use them as written:
no uuid prefix, no preview suffix, the top-level declaration kept, and
no tracking row, so Coolify never removes a shared external volume.
External volume names must be literal; ${VAR} names are rejected.
Existing <uuid>_<name> volumes and their rows are kept on redeploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 16:53:54 +02:00
co-authored by Claude Opus 5.5
parent fe6854c73e
commit ee62f64add
6 changed files with 552 additions and 7 deletions
+94
View File
@@ -105,6 +105,14 @@ function validateDockerComposeForInjection(string $composeYaml, ?string $resourc
}
}
}
if (isset($parsed['volumes']) && is_array($parsed['volumes'])) {
foreach ($parsed['volumes'] as $volumeName => $volumeConfig) {
if (isComposeExternalVolume($volumeConfig)) {
validateComposeExternalVolume($volumeName, $volumeConfig);
}
}
}
}
/**
@@ -302,6 +310,80 @@ function validateComposeNetworkName(string $networkName, string $context = 'netw
}
}
/**
* Tells if a top-level Compose volume declaration is external: `external: true` or the old
* `external: {name: x}` syntax. Docker Compose does not create or remove an external volume.
*/
function isComposeExternalVolume(mixed $declaration): bool
{
if (! is_array($declaration) || ! array_key_exists('external', $declaration)) {
return false;
}
$external = $declaration['external'];
if (is_array($external)) {
return true;
}
return filter_var($external, FILTER_VALIDATE_BOOLEAN);
}
/**
* Returns the top-level declaration of a volume source when the Compose file declares it as
* external, and null for all other sources. The parsers use an external volume as written: no
* "{uuid}_" prefix, no preview suffix and no LocalPersistentVolume row, so that Coolify never
* removes a volume that it does not own.
*
* @param iterable<array-key, mixed> $topLevelVolumes
* @return array<string, mixed>|null
*
* @throws Exception If the external volume has a name that is not safe (see validateComposeExternalVolume())
*/
function composeExternalVolumeDeclaration(iterable $topLevelVolumes, string $source): ?array
{
$declaration = collect($topLevelVolumes)->get($source);
if (! isComposeExternalVolume($declaration)) {
return null;
}
validateComposeExternalVolume($source, $declaration);
return $declaration;
}
/**
* 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
* the resource mounts, and Coolify cannot show or check that volume. Thus variables are rejected.
*
* @param array<string, mixed> $declaration
*
* @throws Exception If the key or the name is not a literal, valid Docker volume name
*/
function validateComposeExternalVolume(int|string $key, array $declaration): void
{
$key = (string) $key;
$label = preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key) === 1 ? "volume {$key}" : 'volume';
$names = [$key];
if (array_key_exists('name', $declaration)) {
$names[] = $declaration['name'];
}
if (is_array($declaration['external'] ?? null) && array_key_exists('name', $declaration['external'])) {
$names[] = $declaration['external']['name'];
}
foreach ($names as $name) {
if (is_string($name) && str_contains($name, '$')) {
throw new Exception(
"Invalid external Docker Compose {$label}: Coolify does not resolve variables in the name of an external volume. Use the literal name of the existing Docker volume."
);
}
if (! is_string($name) || preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name) !== 1) {
throw new Exception(
"Invalid external Docker Compose {$label}. Volume names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores."
);
}
}
}
/**
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
*
@@ -1119,6 +1201,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
} elseif ($type->value() === 'volume') {
if (composeExternalVolumeDeclaration($topLevel->get('volumes'), $source->value()) !== null) {
// Previews share the external volume. It gets no row, so Coolify never removes it.
$volumesParsed->put($index, $volume);
continue;
}
if ($topLevel->get('volumes')->has($source->value())) {
$temp = $topLevel->get('volumes')->get($source->value());
if (data_get($temp, 'driver_opts.type') === 'cifs') {
@@ -2468,6 +2556,12 @@ function serviceParser(Service $resource): Collection
}
}
} elseif ($type->value() === 'volume') {
if (composeExternalVolumeDeclaration($topLevel->get('volumes'), $source->value()) !== null) {
// The external volume gets no row, so Coolify never removes it.
$volumesParsed->put($index, $volume);
continue;
}
if ($topLevel->get('volumes')->has($source->value())) {
$temp = $topLevel->get('volumes')->get($source->value());
if (data_get($temp, 'driver_opts.type') === 'cifs') {
+20 -4
View File
@@ -2867,6 +2867,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
]
);
} elseif ($type->value() === 'volume') {
if (composeExternalVolumeDeclaration($topLevelVolumes, $source->value()) !== null) {
// The external volume gets no row, so Coolify never removes it.
return $volume;
}
if ($topLevelVolumes->has($source->value())) {
$v = $topLevelVolumes->get($source->value());
if (data_get($v, 'driver_opts.type') === 'cifs') {
@@ -3386,8 +3390,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
$server = $resource->destination->server;
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
// External volumes are used as written, also by previews (no prefix and no preview suffix).
$externalTopLevelVolumes = $topLevelVolumes->filter(fn (mixed $volume): bool => isComposeExternalVolume($volume));
if ($pull_request_id !== 0) {
$topLevelVolumes = collect([]);
$topLevelVolumes = collect($externalTopLevelVolumes->all());
}
if ($topLevelVolumes->count() > 0) {
@@ -3418,7 +3424,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, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) {
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $externalTopLevelVolumes, $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', []));
@@ -3457,7 +3463,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, $pull_request_id) {
$serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $externalTopLevelVolumes, $pull_request_id) {
if (is_string($volume)) {
$volume = str($volume);
if ($volume->contains(':') && ! $volume->startsWith('/')) {
@@ -3475,6 +3481,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} elseif (composeExternalVolumeDeclaration($externalTopLevelVolumes, $name->value()) !== null) {
// An external volume is used as written.
} else {
if ($pull_request_id !== 0) {
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
@@ -3542,6 +3550,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} else {
data_set($volume, 'source', $source.':'.$target);
}
} elseif (composeExternalVolumeDeclaration($externalTopLevelVolumes, (string) $source) !== null) {
// An external volume is used as written.
data_set($volume, 'source', $source.':'.$target.($read_only ? ':ro' : ''));
} else {
if ($pull_request_id !== 0) {
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
@@ -3581,7 +3592,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, $pull_request_id) {
$serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $externalTopLevelVolumes, $pull_request_id) {
if (is_string($volume)) {
$volume = str($volume);
if ($volume->contains(':') && ! $volume->startsWith('/')) {
@@ -3599,6 +3610,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
$volume = str("$name:$mount");
} elseif (composeExternalVolumeDeclaration($externalTopLevelVolumes, $name->value()) !== null) {
// An external volume is used as written.
} else {
if ($pull_request_id !== 0) {
$uuid = $resource->uuid;
@@ -3668,6 +3681,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} else {
data_set($volume, 'source', $source.':'.$target);
}
} elseif (composeExternalVolumeDeclaration($externalTopLevelVolumes, (string) $source) !== null) {
// An external volume is used as written.
data_set($volume, 'source', $source.':'.$target.($read_only ? ':ro' : ''));
} else {
if ($pull_request_id === 0) {
$source = $uuid."-$source";