From 247015e30a2368d08b57a4280c80c78ec0d25c20 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:45:32 +0200 Subject: [PATCH] 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. --- bootstrap/helpers/docker.php | 20 ++++++---- .../Feature/ContainerOwnerUuidLabelsTest.php | 37 ++++++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8cba703241..d410696e3a 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -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; diff --git a/tests/Feature/ContainerOwnerUuidLabelsTest.php b/tests/Feature/ContainerOwnerUuidLabelsTest.php index 5e46f7bab2..52da7c52e5 100644 --- a/tests/Feature/ContainerOwnerUuidLabelsTest.php +++ b/tests/Feature/ContainerOwnerUuidLabelsTest.php @@ -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,