fix(docker): match legacy Compose containers by UUID in names

Resolve application owners from dash-separated UUIDs in Compose service or coolify.name labels, and include service-prefixed names in Docker filters.

Add regression coverage for custom Compose start commands, status updates, and UUID boundary matching.
This commit is contained in:
Andras Bacsai
2026-10-09 08:45:32 +02:00
parent ea1199bf8e
commit 247015e30a
2 changed files with 47 additions and 10 deletions
+12 -8
View File
@@ -89,10 +89,12 @@ function containerOwnerUuid(Collection|array|string $labels, string $type): ?str
/**
* Resource that owns a container, from the given candidates, by UUID label or compose project /
* stack namespace (see containerOwnerUuid()). Applications deployed before mid-2024 used the
* deployment directory as compose project; their compose service (the container name) starts
* with the application UUID. The numeric id label is never used: another Coolify instance that
* manages the same server can have a resource with the same id.
* stack namespace (see containerOwnerUuid()). Some older application containers have another
* compose project: the deployment directory (before mid-2024, or a custom Compose start command
* without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also
* the compose service) contains the application UUID as a dash-separated part, for example
* `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used:
* another Coolify instance that manages the same server can have a resource with the same id.
*
* @template TResource of \Illuminate\Database\Eloquent\Model
*
@@ -110,9 +112,11 @@ function resolveContainerOwner(Collection $resources, Collection|array|string $l
return $owner;
}
$composeService = (string) $labels->get('com.docker.compose.service');
$names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')];
return $resources->first(fn ($resource) => $composeService === $resource->uuid || str_starts_with($composeService, $resource->uuid.'-'));
return $resources->first(fn ($resource) => collect($names)->contains(
fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1
));
}
/**
@@ -238,8 +242,8 @@ function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters
$base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument,
];
if ($type === 'application') {
// Deployed before mid-2024 (see resolveContainerOwner()): the container name starts with the UUID.
$commands[] = $base.' '.$filters(['label=coolify.applicationId', "name=^{$uuid}", ...$legacyFilters]).' '.$formatArgument;
// Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID.
$commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument;
}
return $commands;
+35 -2
View File
@@ -151,6 +151,10 @@ describe('owner resolution', function () {
// Deployed before mid-2024: compose project was the deployment directory, the compose service starts with the UUID.
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => $this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'other-instance-uuid-104512123456'], 'application'))->toBeNull()
// Compose application with a custom start command: the compose project is the deployment directory, the container name is `{service}-{uuid}-{suffix}`.
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-'.$this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-other-instance-uuid-104512123456'], 'application'))->toBeNull()
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'coolify.name' => 'public-api-'.$this->application->uuid.'x-104512123456'], 'application'))->toBeNull()
->and(implode("\n", dockerPsByOwnerCommands('application', 'app-uuid')))->not->toContain('label=coolify.applicationId=');
});
@@ -163,9 +167,9 @@ describe('owner resolution', function () {
->and($commands[1])->toContain("--filter 'label=coolify.applicationId'")
->toContain("--filter 'label=com.docker.compose.project=app-uuid'")
->toContain("--filter 'label=coolify.pullRequestId=0'")
// Deployed before mid-2024: the container name starts with the application UUID.
// Compose project is the deployment directory: the container name contains the application UUID.
->and($commands[2])->toContain("--filter 'label=coolify.applicationId'")
->toContain("--filter 'name=^app-uuid'")
->toContain("--filter 'name=app-uuid'")
->toContain("--filter 'label=coolify.pullRequestId=0'")
->and(dockerPsByOwnerCommands('service', 'service-uuid'))->toHaveCount(2);
});
@@ -291,6 +295,35 @@ describe('status updates after a server transfer', function () {
expect($this->application->fresh()->status)->toStartWith('running');
});
test('Sentinel push and status check match a 4.3 compose container deployed with a custom start command', function () {
$containerName = 'public-api-'.$this->application->uuid.'-104512123456';
$labels = [
'coolify.managed' => 'true',
'coolify.applicationId' => (string) $this->application->id,
'coolify.pullRequestId' => '0',
'coolify.name' => $containerName,
// Without --project-name, the compose project is the deployment directory.
'com.docker.compose.project' => 'r8wkc0gk0o4ws8gs4s8wwgs4',
'com.docker.compose.service' => 'public-api',
];
$job = new PushServerUpdateJob($this->server, ['containers' => [[
'name' => $containerName, 'state' => 'running', 'health_status' => 'healthy', 'labels' => $labels,
]]]);
$job->handle();
expect($this->application->fresh()->status)->toStartWith('running');
$this->application->update(['status' => 'exited']);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
GetContainersStatus::run($this->server->fresh(), collect([[
'Name' => '/'.$containerName,
'State' => ['Status' => 'running', 'Health' => ['Status' => 'healthy']],
'RestartCount' => 0,
'Config' => ['Labels' => $labels],
]]), collect());
expect($this->application->fresh()->status)->toStartWith('running');
});
test('container status check matches an older application container by compose project', function () {
$container = [
'Name' => '/'.$this->application->uuid,