From af983a29d82f89aec343128650d78fd02a51a9d4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:57:56 +0200 Subject: [PATCH] refactor(docker): identify containers by resource UUID instead of numeric id Container ownership labels now use UUIDs: coolify.applicationUuid, coolify.serviceUuid, coolify.service.subUuid and coolify.databaseUuid. Numeric ids differ between Coolify instances, so after a server transfer the target could not find existing containers: redeploys left the old container running and service statuses were wrong. Containers are matched by UUID label first, then by the compose project (Swarm: stack namespace), which is the resource UUID since July 2024, and last by the older numeric id label. Existing containers keep working without a redeploy and get the UUID labels on the next deploy or restart. --- .ai/lessons.md | 5 + .../Application/CleanupPreviewDeployment.php | 2 +- app/Actions/Application/StopApplication.php | 4 +- .../Application/StopApplicationOneServer.php | 2 +- .../Application/StopApplicationPreview.php | 2 +- app/Actions/Docker/GetContainersStatus.php | 41 ++- app/Actions/Service/DeleteService.php | 13 +- app/Actions/Shared/ComplexStatusCheck.php | 4 +- .../Api/ApplicationsController.php | 2 +- .../Controllers/Api/DatabasesController.php | 2 +- .../Controllers/Api/ServicesController.php | 2 +- app/Jobs/ApplicationDeploymentJob.php | 10 +- .../CleanupOrphanedPreviewContainersJob.php | 9 +- app/Jobs/DeleteResourceJob.php | 2 +- app/Jobs/PushServerUpdateJob.php | 23 +- .../Project/Application/InternalAccess.php | 2 +- app/Livewire/Project/Application/Previews.php | 2 +- .../Shared/ExecuteContainerCommand.php | 2 +- app/Livewire/Project/Shared/Logs.php | 8 +- app/Mcp/Tools/GetLogs.php | 2 +- app/Models/Application.php | 8 +- bootstrap/helpers/docker.php | 211 +++++++++++-- bootstrap/helpers/parsers.php | 6 +- bootstrap/helpers/shared.php | 6 +- .../Feature/ContainerOwnerUuidLabelsTest.php | 276 ++++++++++++++++++ .../DeleteResourceJobAtomicityTest.php | 16 +- .../Feature/Jobs/PushServerUpdateJobTest.php | 44 ++- 27 files changed, 604 insertions(+), 102 deletions(-) create mode 100644 tests/Feature/ContainerOwnerUuidLabelsTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 06d5343213..834b839331 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -78,3 +78,8 @@ ## Format only your own files - `pint --dirty` also rewrites uncommitted files that belong to other work in the tree. When the tree has unrelated changes, pass your changed paths to Pint. + +## Match containers by UUID, never by numeric id +- Container ownership labels are `coolify.applicationUuid`, `coolify.serviceUuid`, `coolify.service.subUuid`, and `coolify.databaseUuid`. Numeric ids change when a server moves to another instance. +- Use `resolveContainerOwner()`, `resolveServiceContainerOwner()`, and `containersOwnedBy()` / `dockerPsByOwnerCommands()` from `bootstrap/helpers/docker.php`. Order: UUID label, then `com.docker.compose.project` (the UUID only since July 2024), then the local numeric id label of older containers. +- Read owners from the flat label list. `Arr::undot()` breaks `com.docker.compose.project` because `com.docker.compose.project.config_files` is nested under it. diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php index 803eef3983..6c408bdf8b 100644 --- a/app/Actions/Application/CleanupPreviewDeployment.php +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -157,7 +157,7 @@ class CleanupPreviewDeployment } else { $containers = getCurrentApplicationContainerStatus( $server, - $application->id, + $application, $pull_request_id ); diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index a0e50ede79..af649692b2 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -37,8 +37,8 @@ class StopApplication } $containers = $previewDeployments - ? getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true) - : getCurrentApplicationContainerStatus($server, $application->id, 0); + ? getCurrentApplicationContainerStatus($server, $application, includePullrequests: true) + : getCurrentApplicationContainerStatus($server, $application, 0); $containersToStop = $containers->pluck('Names')->toArray(); $timeout = $application->settings->stopGracePeriodSeconds(); diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 5b40702cd8..a7fdb012d3 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -19,7 +19,7 @@ class StopApplicationOneServer return 'Server is not functional'; } try { - $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $containers = getCurrentApplicationContainerStatus($server, $application, 0); $timeout = $application->settings->stopGracePeriodSeconds(); if ($containers->count() > 0) { diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php index 954bde879a..44a6ee15bf 100644 --- a/app/Actions/Application/StopApplicationPreview.php +++ b/app/Actions/Application/StopApplicationPreview.php @@ -14,7 +14,7 @@ class StopApplicationPreview { $application = $preview->application; $server = $application->destination->server; - $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); + $containers = getCurrentApplicationContainerStatus($server, $application, $preview->pull_request_id); foreach ($containers->pluck('Names') as $containerName) { $escapedContainerName = escapeshellarg($containerName); diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index c69bd1855f..794c581011 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -124,18 +124,22 @@ class GetContainersStatus $healthSuffix = $containerHealth ?? 'unknown'; $containerStatus = "$containerStatus:$healthSuffix"; } - $labels = Arr::undot(format_docker_labels_to_json($labels)); + $flatLabels = format_docker_labels_to_json($labels); + $labels = Arr::undot($flatLabels); if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { continue; } - $applicationId = data_get($labels, 'coolify.applicationId'); - if ($applicationId) { + // Containers are matched by owner UUID; numeric ids differ between instances. + $isApplicationContainer = isContainerOfType($flatLabels, 'application'); + $isServiceContainer = isContainerOfType($flatLabels, 'service'); + [$parentService, $serviceSubType, $serviceSubResource] = resolveServiceContainerOwner($services, $flatLabels); + if ($isApplicationContainer) { $pullRequestId = data_get($labels, 'coolify.pullRequestId'); if ($pullRequestId) { - if (str($applicationId)->contains('-')) { - $applicationId = str($applicationId)->before('-'); - } - $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first(); + $applicationId = resolveContainerApplicationId($this->applications, $flatLabels); + $preview = $applicationId + ? ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first() + : null; if ($preview) { $foundApplicationPreviews[] = $preview->id; $statusFromDb = $preview->status; @@ -154,7 +158,8 @@ class GetContainersStatus // Notify user that this container should not be there. } } else { - $application = $this->applications->where('id', $applicationId)->first(); + $application = resolveContainerOwner($this->applications, $flatLabels, 'application'); + $applicationId = $application?->id; if ($application) { $foundApplications[] = $application->id; if ($application->container_present !== true) { @@ -199,9 +204,8 @@ class GetContainersStatus if ($uuid) { if ($type === 'service') { - $database_id = data_get($labels, 'coolify.service.subId'); - if ($database_id) { - $service_db = ServiceDatabase::where('id', $database_id)->first(); + if ($serviceSubResource instanceof ServiceDatabase) { + $service_db = $serviceSubResource; if ($service_db) { $proxyUuid = $service_db->uuid; $isPublic = data_get($service_db, 'is_public'); @@ -289,14 +293,13 @@ class GetContainersStatus $foundDatabases[] = 0; } } - $serviceLabelId = data_get($labels, 'coolify.serviceId'); - if ($serviceLabelId) { - $subType = data_get($labels, 'coolify.service.subType'); - $subId = data_get($labels, 'coolify.service.subId'); - $parentService = $services->where('id', $serviceLabelId)->first(); + if ($isServiceContainer) { if (! $parentService) { continue; } + $serviceLabelId = $parentService->id; + $subType = $serviceSubType; + $subId = $serviceSubResource?->id; // Store container status for aggregation if (! isset($this->serviceContainerStatuses)) { @@ -319,11 +322,7 @@ class GetContainersStatus } // Mark service as found - if ($subType === 'application') { - $service = $parentService->applications()->where('id', $subId)->first(); - } else { - $service = $parentService->databases()->where('id', $subId)->first(); - } + $service = $serviceSubResource; if ($service) { $foundServices[] = "$service->id-$service->name"; } diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 2295744f25..7c544692b5 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -56,18 +56,19 @@ class DeleteService private function removeContainers(Service $service, ServiceApplication|ServiceDatabase|null $subresource = null): void { - $serviceId = (int) $service->id; - $filters = "--filter label=coolify.serviceId={$serviceId}"; + $filters = []; + $legacyFilters = []; if ($subresource !== null) { - // Applications and databases are separate tables, so an id alone can match the other type. $subType = $subresource instanceof ServiceDatabase ? 'database' : 'application'; - $subId = (int) $subresource->id; - $filters .= " --filter label=coolify.service.subId={$subId} --filter label=coolify.service.subType={$subType}"; + $filters = ["label=coolify.service.subUuid={$subresource->uuid}", "label=coolify.service.subType={$subType}"]; + // Containers from before the UUID labels: the compose service key is the subresource name. + $legacyFilters = ["label=com.docker.compose.service={$subresource->name}", "label=coolify.service.subType={$subType}"]; } // One sh -c line, so non-root servers run the whole script with sudo. A leading variable // assignment would become "sudo container_ids=...", which sudo rejects. - $script = "container_ids=\$(docker ps -aq {$filters}); [ -z \"\$container_ids\" ] || docker rm -f \$container_ids"; + $script = containerIdsByOwnerScript('service', $service->uuid, $filters, legacyExtraFilters: $legacyFilters, legacyId: (int) $service->id) + .'; [ -z "$container_ids" ] || docker rm -f $container_ids'; instant_remote_process(['sh -c '.escapeshellarg($script)], $service->server); } diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index 3649be9865..3c400e634b 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -29,7 +29,9 @@ class ComplexStatusCheck continue; } } - $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); + $script = containerIdsByOwnerScript('application', $application->uuid, ['label=coolify.pullRequestId=0'], all: false, legacyId: (int) $application->id) + .'; [ -z "$container_ids" ] || docker container inspect $container_ids --format \'{{json .}}\''; + $containers = instant_remote_process(['sh -c '.escapeshellarg($script)], $server, false); $containers = format_docker_command_output_to_json($containers); if ($containers->count() > 0) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 128744a549..679e7c2581 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2589,7 +2589,7 @@ class ApplicationsController extends Controller } } - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pullRequestId); + $containers = getCurrentApplicationContainerStatus($application->destination->server, $application, $pullRequestId); if ($containers->count() == 0) { return response()->json([ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b4b9bfbb5f..81035eca03 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2693,7 +2693,7 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } - $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database); if ($containers->count() == 0) { return response()->json([ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index fdcaf57f5b..63a1a8daf7 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -966,7 +966,7 @@ class ServicesController extends Controller } $name = "{$subServiceName}-{$service->uuid}"; - $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service, $name); $container = $containers->first(); if (! $container) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d886c905b9..6ff601cd17 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3759,7 +3759,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return escapeDollarSign($value); }); } - $labels = $labels->merge(defaultLabels($this->application->id, $this->application->uuid, $this->application->project()->name, $this->application->name, $this->application->environment->name, $this->pull_request_id))->toArray(); + $labels = $labels->merge(defaultLabels($this->application->uuid, $this->application->uuid, $this->application->project()->name, $this->application->name, $this->application->environment->name, $this->pull_request_id))->toArray(); // Check for custom HEALTHCHECK if ($this->application->build_pack === 'dockerfile' || $this->application->dockerfile) { @@ -4551,12 +4551,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Removing old containers.'); if ($this->newVersionIsHealthy || $force) { if ($this->application->settings->is_consistent_container_name_enabled) { - $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); $this->containerNamesToRemove($containers)->each(function (string $containerName) { $this->graceful_shutdown_container($containerName); }); } else { - $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id); @@ -5432,7 +5432,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (empty($this->application->pre_deployment_command)) { return; } - $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); if ($containers->count() == 0) { $this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.'); @@ -5477,7 +5477,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).'); - $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); if ($containers->count() == 0) { $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.'); diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php index e74cba5548..d3286699fa 100644 --- a/app/Jobs/CleanupOrphanedPreviewContainersJob.php +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -141,16 +141,11 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe } /** - * Extract application ID from container labels. + * Id of the application that owns the container (see resolveContainerApplicationId()). */ private function extractApplicationId($container): ?int { - $labels = data_get($container, 'Labels', ''); - if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) { - return (int) $matches[1]; - } - - return null; + return resolveContainerApplicationId(collect(), (string) data_get($container, 'Labels', '')); } /** diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index b957f0a388..98df673335 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -264,7 +264,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); instant_remote_process(["docker stack rm {$escapedStackName}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray(); + $containers = getCurrentApplicationContainerStatus($server, $application, $pull_request_id)->toArray(); $this->stopPreviewContainers($containers, $server); } } catch (\Throwable $e) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index bf08095c7c..1324c212ed 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -257,8 +257,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { $this->foundLogDrainContainer = true; } - if ($labels->has('coolify.applicationId')) { - $applicationId = $labels->get('coolify.applicationId'); + // Containers are matched by owner UUID; numeric ids differ between instances. + if (isContainerOfType($labels, 'application')) { + $applicationId = resolveContainerApplicationId($this->applications, $labels); + if ($applicationId === null) { + continue; + } + $applicationId = (string) $applicationId; $pullRequestId = $labels->get('coolify.pullRequestId', '0'); try { if ($pullRequestId === '0') { @@ -300,13 +305,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } catch (\Exception $e) { } - } elseif ($labels->has('coolify.serviceId')) { - $serviceId = $labels->get('coolify.serviceId'); - $subType = $labels->get('coolify.service.subType'); - $subId = $labels->get('coolify.service.subId'); - if (empty(trim((string) $subId))) { + } elseif (isContainerOfType($labels, 'service')) { + [$service, $subType, $servicePart] = resolveServiceContainerOwner($this->services, $labels); + if (! $service || ! $servicePart) { continue; } + $serviceId = (string) $service->id; + $subId = (string) $servicePart->id; if ($subType === 'application') { $this->foundServiceApplicationIds->push($subId); // Store container status for aggregation @@ -482,8 +487,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'docker_compose_raw', ]) ->with([ - 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', - 'databases:id,service_id,status,last_online_at,is_public,name', + 'applications:id,uuid,name,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', + 'databases:id,uuid,name,service_id,status,last_online_at,is_public', ]) ->get(); } diff --git a/app/Livewire/Project/Application/InternalAccess.php b/app/Livewire/Project/Application/InternalAccess.php index 9fc1db43a8..684ce7046a 100644 --- a/app/Livewire/Project/Application/InternalAccess.php +++ b/app/Livewire/Project/Application/InternalAccess.php @@ -28,7 +28,7 @@ class InternalAccess extends Component try { $containers = getCurrentApplicationContainerStatus( $this->application->destination->server, - $this->application->id, + $this->application, 0 ); $currentContainer = $containers->first( diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 79a393c192..14da6ac62c 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -307,7 +307,7 @@ class Previews extends Component if ($this->application->destination->server->isSwarm()) { instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $containers = getCurrentApplicationContainerStatus($server, $this->application, $pull_request_id)->toArray(); $this->stopContainers($containers, $server); } diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 91064e6895..4eb05ad0ad 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -100,7 +100,7 @@ class ExecuteContainerCommand extends Component ], ]); } else { - $containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); + $containers = getCurrentApplicationContainerStatus($server, $this->resource, includePullrequests: true); } foreach ($containers as $container) { // if container state is running diff --git a/app/Livewire/Project/Shared/Logs.php b/app/Livewire/Project/Shared/Logs.php index 553b0485ef..f5e4f0c33a 100644 --- a/app/Livewire/Project/Shared/Logs.php +++ b/app/Livewire/Project/Shared/Logs.php @@ -88,20 +88,20 @@ class Logs extends Component } // Docker labels differ by resource type: - // applications → coolify.applicationId, services → coolify.serviceId, databases → coolify.databaseId + // applications → coolify.applicationUuid, services → coolify.serviceUuid, databases → coolify.databaseUuid $containers = match (true) { $this->resource instanceof Application => getCurrentApplicationContainerStatus( $server, - $this->resource->id, + $this->resource, includePullrequests: true ), $this->resource instanceof Service => getCurrentServiceContainerStatus( $server, - $this->resource->id + $this->resource ), default => getCurrentDatabaseContainerStatus( $server, - $this->resource->id + $this->resource ), }; diff --git a/app/Mcp/Tools/GetLogs.php b/app/Mcp/Tools/GetLogs.php index 6635fcc581..b90df64caf 100644 --- a/app/Mcp/Tools/GetLogs.php +++ b/app/Mcp/Tools/GetLogs.php @@ -223,7 +223,7 @@ class GetLogs extends Tool if (! $server) { throw new \RuntimeException('Application has no server destination.'); } - $containers = getCurrentApplicationContainerStatus($server, $resource->id); + $containers = getCurrentApplicationContainerStatus($server, $resource); if ($containers->count() === 0) { throw new \RuntimeException('Application has no running containers.'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 987a752605..255967644b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -530,8 +530,8 @@ class Application extends BaseModel public function getContainersToStop(Server $server, bool $previewDeployments = false): array { $containers = $previewDeployments - ? getCurrentApplicationContainerStatus($server, $this->id, includePullrequests: true) - : getCurrentApplicationContainerStatus($server, $this->id, 0); + ? getCurrentApplicationContainerStatus($server, $this, includePullrequests: true) + : getCurrentApplicationContainerStatus($server, $this, 0); return $containers->pluck('Names')->toArray(); } @@ -2159,8 +2159,8 @@ class Application extends BaseModel if (! $labels->contains('coolify.managed')) { $labels->push('coolify.managed=true'); } - if (! $labels->contains('coolify.applicationId')) { - $labels->push('coolify.applicationId='.$this->id); + if (! $labels->contains('coolify.applicationUuid='.$this->uuid)) { + $labels->push('coolify.applicationUuid='.$this->uuid); } if (! $labels->contains('coolify.type')) { $labels->push('coolify.type=application'); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 0afca06475..52b401e23a 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -4,8 +4,11 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Server; +use App\Models\Service; use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -56,12 +59,188 @@ function caddyTrafficAppKey(string $uuid, ?string $serviceName = null): string return (string) preg_replace('/[^A-Za-z0-9-]+/', '-', $key); } -function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection +/** + * UUID of the resource that owns a container. + * + * New containers carry `coolify.{type}Uuid`. Containers created before that label carry a + * numeric `coolify.{type}Id` instead. Ids change when a server moves to another instance, so + * for those containers the owner is read from the compose project (Swarm: stack namespace), + * which is the resource UUID for deployments since mid-2024. Use resolveContainerOwner() to + * also fall back to the numeric id for older containers. + * + * @param 'application'|'service'|'database' $type + */ +function containerOwnerUuid(Collection|array|string $labels, string $type): ?string +{ + $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); + + $uuid = $labels->get("coolify.{$type}Uuid"); + if (filled($uuid)) { + return (string) $uuid; + } + + if (! $labels->has("coolify.{$type}Id")) { + return null; + } + + $legacyUuid = $labels->get('com.docker.compose.project') ?? $labels->get('com.docker.stack.namespace'); + + return filled($legacyUuid) ? (string) $legacyUuid : null; +} + +/** + * Resource that owns a container, from the given candidates. + * + * Order: UUID label, compose project / stack namespace (see containerOwnerUuid()), then the + * numeric id label of containers without a UUID label. The id is only correct on the instance + * that created the container, so it is the last fallback. + * + * @template TResource of \Illuminate\Database\Eloquent\Model + * + * @param Collection $resources + * @param 'application'|'service'|'database' $type + * @return TResource|null + */ +function resolveContainerOwner(Collection $resources, Collection|array|string $labels, string $type): mixed +{ + $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); + + $uuid = containerOwnerUuid($labels, $type); + $owner = $uuid ? $resources->firstWhere('uuid', $uuid) : null; + if ($owner || filled($labels->get("coolify.{$type}Uuid"))) { + return $owner; + } + + $legacyId = $labels->get("coolify.{$type}Id"); + + return is_numeric($legacyId) ? $resources->first(fn ($resource) => (int) $resource->id === (int) $legacyId) : null; +} + +/** + * Id of the application that owns a container, also for applications not in $applications + * (for example the parent of a preview). Same order as resolveContainerOwner(). + */ +function resolveContainerApplicationId(Collection $applications, Collection|array|string $labels): ?int +{ + $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); + + $owner = resolveContainerOwner($applications, $labels, 'application'); + if ($owner) { + return (int) $owner->id; + } + + $uuid = containerOwnerUuid($labels, 'application'); + $id = $uuid ? Application::withTrashed()->where('uuid', $uuid)->value('id') : null; + if ($id !== null || filled($labels->get('coolify.applicationUuid'))) { + return $id !== null ? (int) $id : null; + } + + $legacyId = $labels->get('coolify.applicationId'); + + return is_numeric($legacyId) ? (int) $legacyId : null; +} + +/** + * Whether a container belongs to a resource of the given type (by UUID or older numeric id label). + * + * @param 'application'|'service'|'database' $type + */ +function isContainerOfType(Collection|array|string $labels, string $type): bool +{ + $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); + + return filled($labels->get("coolify.{$type}Uuid")) || filled($labels->get("coolify.{$type}Id")); +} + +/** + * Service, sub type, and service application/database that own a service container. + * + * The part is matched by `coolify.service.subUuid`; containers from before that label are matched + * by their compose service key (the part name), then by the older numeric `coolify.service.subId`. + * + * @param Collection $services + * @return array{0: ?Service, 1: ?string, 2: ServiceApplication|ServiceDatabase|null} + */ +function resolveServiceContainerOwner(Collection $services, Collection|array $flatLabels): array +{ + $labels = collect($flatLabels); + $service = resolveContainerOwner($services, $labels, 'service'); + if (! $service) { + return [null, null, null]; + } + + $subType = $labels->get('coolify.service.subType'); + $parts = $subType === 'database' ? $service->databases : $service->applications; + $subUuid = $labels->get('coolify.service.subUuid'); + if (filled($subUuid)) { + return [$service, $subType, $parts->firstWhere('uuid', $subUuid)]; + } + + $legacySubId = $labels->get('coolify.service.subId'); + $part = $parts->firstWhere('name', $labels->get('com.docker.compose.service')) + ?? (is_numeric($legacySubId) ? $parts->first(fn ($part) => (int) $part->id === (int) $legacySubId) : null); + + return [$service, $subType, $part]; +} + +/** + * Remote commands that list, as JSON lines, the containers owned by a resource (see containerOwnerUuid()). + * + * @param 'application'|'service'|'database' $type + * @param list $extraFilters Additional `--filter` values, e.g. `label=coolify.pullRequestId=0`. + * @param list|null $legacyExtraFilters Filters for containers without the UUID label; defaults to $extraFilters. + * @param int|null $legacyId Local numeric id: also match older containers by `coolify.{type}Id` (may repeat a container). + * @return list + */ +function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters = [], string $format = '{{json .}}', bool $all = true, ?array $legacyExtraFilters = null, ?int $legacyId = null): array +{ + $filters = fn (array $values) => collect($values)->map(fn (string $filter) => '--filter '.escapeshellarg($filter))->implode(' '); + $base = 'docker ps'.($all ? ' -a' : ''); + $formatArgument = '--format '.escapeshellarg($format); + $legacyFilters = $legacyExtraFilters ?? $extraFilters; + + $commands = [ + $base.' '.$filters(["label=coolify.{$type}Uuid={$uuid}", ...$extraFilters]).' '.$formatArgument, + $base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument, + ]; + if ($legacyId !== null) { + $commands[] = $base.' '.$filters(["label=coolify.{$type}Id={$legacyId}", ...$legacyFilters]).' '.$formatArgument; + } + + return $commands; +} + +/** + * Run dockerPsByOwnerCommands() for a resource and return each container once. + * + * @param 'application'|'service'|'database' $type + */ +function containersOwnedBy(Server $server, string $type, Model $resource): Collection +{ + $output = instant_remote_process(dockerPsByOwnerCommands($type, $resource->uuid, legacyId: (int) $resource->id), $server); + + return format_docker_command_output_to_json($output)->filter()->unique('ID')->values(); +} + +/** + * One shell line that sets $container_ids to the ids of the containers owned by a resource. + * + * @param 'application'|'service'|'database' $type + * @param list $extraFilters + * @param list|null $legacyExtraFilters + */ +function containerIdsByOwnerScript(string $type, string $uuid, array $extraFilters = [], bool $all = true, ?array $legacyExtraFilters = null, ?int $legacyId = null): string +{ + $commands = dockerPsByOwnerCommands($type, $uuid, $extraFilters, '{{.ID}}', $all, $legacyExtraFilters, $legacyId); + + return 'container_ids=$({ '.implode('; ', $commands).'; } | sort -u)'; +} + +function getCurrentApplicationContainerStatus(Server $server, Application $application, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { $containers = collect([]); if (! $server->isSwarm()) { - $containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server); - $containers = format_docker_command_output_to_json($containers); + $containers = containersOwnedBy($server, 'application', $application); $containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) { $labels = data_get($container, 'Labels'); @@ -105,35 +284,29 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul return $containers; } -function getCurrentServiceContainerStatus(Server $server, int $id): Collection +function getCurrentServiceContainerStatus(Server $server, Service $service): Collection { $containers = collect([]); if (! $server->isSwarm()) { - $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server); - $containers = format_docker_command_output_to_json($containers); - - return $containers->filter(); + return containersOwnedBy($server, 'service', $service); } return $containers; } -function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +function getCurrentDatabaseContainerStatus(Server $server, Model $database): Collection { $containers = collect([]); if (! $server->isSwarm()) { - $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); - $containers = format_docker_command_output_to_json($containers); - - return $containers->filter(); + return containersOwnedBy($server, 'database', $database); } return $containers; } -function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection +function getCurrentServiceSubContainerStatus(Server $server, Service $service, string $name): Collection { - return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name); + return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $service), $name); } function filterServiceSubContainersByName(Collection $containers, string $name): Collection @@ -411,7 +584,7 @@ function defaultDatabaseLabels($database) $labels = collect([]); $labels->push('coolify.managed=true'); $labels->push('coolify.type=database'); - $labels->push('coolify.databaseId='.$database->id); + $labels->push('coolify.databaseUuid='.$database->uuid); $labels->push('coolify.resourceName='.Str::slug($database->name)); $labels->push('coolify.serviceName='.Str::slug($database->name)); $labels->push('coolify.projectName='.Str::slug($database->project()->name)); @@ -421,12 +594,12 @@ function defaultDatabaseLabels($database) return $labels; } -function defaultLabels($id, $name, string $projectName, string $resourceName, string $environment, $pull_request_id = 0, string $type = 'application', $subType = null, $subId = null, $subName = null) +function defaultLabels(string $uuid, $name, string $projectName, string $resourceName, string $environment, $pull_request_id = 0, string $type = 'application', $subType = null, ?string $subUuid = null, $subName = null) { $labels = collect([]); $labels->push('coolify.managed=true'); $labels->push('coolify.version='.config('constants.coolify.version')); - $labels->push('coolify.'.$type.'Id='.$id); + $labels->push('coolify.'.$type.'Uuid='.$uuid); $labels->push("coolify.type=$type"); $labels->push('coolify.name='.Str::slug($name)); $labels->push('coolify.resourceName='.Str::slug($resourceName)); @@ -436,7 +609,7 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st $labels->push('coolify.pullRequestId='.$pull_request_id); if ($type === 'service') { - $subId && $labels->push('coolify.service.subId='.$subId); + $subUuid && $labels->push('coolify.service.subUuid='.$subUuid); $subType && $labels->push('coolify.service.subType='.$subType); $subName && $labels->push('coolify.service.subName='.Str::slug($subName)); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7187cd639d..66a834c1db 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1835,7 +1835,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } $defaultLabels = defaultLabels( - id: $resource->id, + uuid: $resource->uuid, name: $containerName, projectName: $resource->project()->name, resourceName: $resource->name, @@ -3098,13 +3098,13 @@ function serviceParser(Service $resource): Collection : collect([]); $defaultLabels = defaultLabels( - id: $resource->id, + uuid: $resource->uuid, name: $containerName, projectName: $resource->project()->name, resourceName: $resource->name, type: 'service', subType: $savedService instanceof ServiceDatabase ? 'database' : 'application', - subId: $savedService->id, + subUuid: $savedService->uuid, subName: $savedService->human_name ?? $savedService->name, environment: $resource->environment->name, ); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7b094e268d..d50d1f28fc 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3193,13 +3193,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? $savedService->noindexDomains() : collect([]); $defaultLabels = defaultLabels( - id: $resource->id, + uuid: $resource->uuid, name: $containerName, projectName: $resource->project()->name, resourceName: $resource->name, type: 'service', subType: $isDatabase ? 'database' : 'application', - subId: $savedService->id, + subUuid: $savedService->uuid, subName: $savedService->name, environment: $resource->environment->name, ); @@ -4122,7 +4122,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $defaultLabels = defaultLabels( - id: $resource->id, + uuid: $resource->uuid, name: $containerName, projectName: $resource->project()->name, resourceName: $resource->name, diff --git a/tests/Feature/ContainerOwnerUuidLabelsTest.php b/tests/Feature/ContainerOwnerUuidLabelsTest.php new file mode 100644 index 0000000000..d8d93a175b --- /dev/null +++ b/tests/Feature/ContainerOwnerUuidLabelsTest.php @@ -0,0 +1,276 @@ + 0, 'fqdn' => 'https://coolify.test']); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'exited', + ]); +}); + +describe('label producers', function () { + test('application and service labels carry UUIDs, not numeric ids', function () { + $labels = defaultLabels('app-uuid', 'app-uuid', 'Project', 'Resource', 'production'); + $serviceLabels = defaultLabels('service-uuid', 'web-service-uuid', 'Project', 'Resource', 'production', type: 'service', subType: 'application', subUuid: 'part-uuid', subName: 'web'); + + expect($labels)->toContain('coolify.applicationUuid=app-uuid') + ->and($labels->filter(fn (string $label) => str_starts_with($label, 'coolify.applicationId=')))->toBeEmpty() + ->and($serviceLabels)->toContain('coolify.serviceUuid=service-uuid') + ->toContain('coolify.service.subUuid=part-uuid') + ->toContain('coolify.service.subType=application') + ->and($serviceLabels->filter(fn (string $label) => preg_match('/^coolify\.(serviceId|service\.subId)=/', $label)))->toBeEmpty(); + }); + + test('database labels carry the database UUID', function () { + $database = StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_password' => 'secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $labels = defaultDatabaseLabels($database); + + expect($labels)->toContain("coolify.databaseUuid={$database->uuid}") + ->and($labels->filter(fn (string $label) => str_starts_with($label, 'coolify.databaseId=')))->toBeEmpty(); + }); +}); + +describe('container lookups for services and databases', function () { + test('database and service lookups match the UUID label, the compose project, and the older id label', function () { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server->update(['private_key_id' => $privateKey->id]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $database = StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_password' => 'secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push($process->command); + + return Process::result(output: ''); + }); + + getCurrentDatabaseContainerStatus($this->server->fresh(), $database); + getCurrentServiceContainerStatus($this->server->fresh(), $service); + + expect($commands->implode("\n")) + ->toContain("label=coolify.databaseUuid={$database->uuid}") + ->toContain("label=com.docker.compose.project={$database->uuid}") + ->toContain("label=coolify.databaseId={$database->id}") + ->toContain("label=coolify.serviceUuid={$service->uuid}") + ->toContain("label=com.docker.compose.project={$service->uuid}") + ->toContain("label=coolify.serviceId={$service->id}"); + }); + + test('service part containers are matched by UUID label, compose service name, then older id', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $part = ServiceApplication::create(['service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine']); + $services = collect([$service->fresh()]); + $partOf = fn (array $labels) => resolveServiceContainerOwner($services, $labels)[2]?->id; + + expect($partOf(['coolify.serviceUuid' => $service->uuid, 'coolify.service.subType' => 'application', 'coolify.service.subUuid' => $part->uuid]))->toBe($part->id) + // Moved from another instance: foreign ids, but compose project and service name still match. + ->and($partOf(['coolify.serviceId' => '999', 'coolify.service.subType' => 'application', 'coolify.service.subId' => '999', 'com.docker.compose.project' => $service->uuid, 'com.docker.compose.service' => 'web']))->toBe($part->id) + // Older container with another compose project: local ids. + ->and($partOf(['coolify.serviceId' => (string) $service->id, 'coolify.service.subType' => 'application', 'coolify.service.subId' => (string) $part->id, 'com.docker.compose.project' => 'other']))->toBe($part->id); + }); +}); + +describe('owner resolution', function () { + test('owner UUID comes from the UUID label, or from the compose project or stack for older containers', function (array $labels, ?string $expected) { + expect(containerOwnerUuid($labels, 'application'))->toBe($expected); + })->with([ + 'uuid label' => [['coolify.applicationUuid' => 'app-uuid', 'com.docker.compose.project' => 'other'], 'app-uuid'], + 'legacy id + compose project' => [['coolify.applicationId' => '78', 'com.docker.compose.project' => 'app-uuid'], 'app-uuid'], + 'legacy id + swarm stack' => [['coolify.applicationId' => '78', 'com.docker.stack.namespace' => 'app-uuid'], 'app-uuid'], + 'legacy id without project' => [['coolify.applicationId' => '78'], null], + 'not an application container' => [['coolify.serviceUuid' => 'svc', 'com.docker.compose.project' => 'svc'], null], + ]); + + test('owner UUID is read from the raw docker label string', function () { + expect(containerOwnerUuid('coolify.applicationId=78,com.docker.compose.project=app-uuid,coolify.pullRequestId=3', 'application')) + ->toBe('app-uuid'); + }); + + test('containers from before mid-2024 fall back to their local numeric id', function () { + $applications = collect([$this->application]); + $olderContainer = ['coolify.applicationId' => (string) $this->application->id, 'com.docker.compose.project' => 'deployment-uuid']; + + expect(resolveContainerOwner($applications, $olderContainer, 'application')?->id)->toBe($this->application->id) + // A UUID label is never overridden by the numeric id. + ->and(resolveContainerOwner($applications, ['coolify.applicationUuid' => 'unknown', 'coolify.applicationId' => (string) $this->application->id], 'application'))->toBeNull() + ->and(dockerPsByOwnerCommands('application', 'app-uuid', legacyId: 42))->toHaveCount(3) + ->and(dockerPsByOwnerCommands('application', 'app-uuid', legacyId: 42)[2])->toContain("--filter 'label=coolify.applicationId=42'"); + }); + + test('docker ps commands match the UUID label and the compose project of older containers', function () { + $commands = dockerPsByOwnerCommands('application', 'app-uuid', ['label=coolify.pullRequestId=0']); + + expect($commands)->toHaveCount(2) + ->and($commands[0])->toContain("--filter 'label=coolify.applicationUuid=app-uuid'") + ->toContain("--filter 'label=coolify.pullRequestId=0'") + ->and($commands[1])->toContain("--filter 'label=coolify.applicationId'") + ->toContain("--filter 'label=com.docker.compose.project=app-uuid'") + ->toContain("--filter 'label=coolify.pullRequestId=0'"); + }); +}); + +describe('status updates after a server transfer', function () { + test('Sentinel push matches an older application container by compose project, not by its foreign id', function () { + $otherApplication = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $job = new PushServerUpdateJob($this->server, ['containers' => [[ + 'name' => $this->application->uuid.'-20260928T212126', + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + // The source instance id of this container equals another application here. + 'coolify.applicationId' => (string) $otherApplication->id, + 'coolify.pullRequestId' => '0', + 'com.docker.compose.project' => $this->application->uuid, + 'com.docker.compose.service' => $this->application->uuid.'-20260928T212126', + ], + ]]]); + $job->handle(); + + expect($job->foundApplicationIds->all())->toContain((string) $this->application->id) + ->not->toContain((string) $otherApplication->id) + ->and($this->application->fresh()->status)->toStartWith('running'); + }); + + test('Sentinel push matches a new application container by its UUID label', function () { + $job = new PushServerUpdateJob($this->server, ['containers' => [[ + 'name' => $this->application->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => 'true', + 'coolify.applicationUuid' => $this->application->uuid, + 'coolify.pullRequestId' => '0', + 'com.docker.compose.service' => $this->application->uuid, + ], + ]]]); + $job->handle(); + + expect($job->foundApplicationIds->all())->toContain((string) $this->application->id) + ->and($this->application->fresh()->status)->toStartWith('running'); + }); + + test('Sentinel push and status check still match containers from before mid-2024 by their local id', function () { + $labels = [ + 'coolify.managed' => 'true', + 'coolify.applicationId' => (string) $this->application->id, + 'coolify.pullRequestId' => '0', + // Before July 2024 the compose project was the deployment directory, not the application UUID. + 'com.docker.compose.project' => 'lwk8ssk0ck4co4w0skkowwss', + 'com.docker.compose.service' => $this->application->uuid, + ]; + + $job = new PushServerUpdateJob($this->server, ['containers' => [[ + 'name' => $this->application->uuid, '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' => '/'.$this->application->uuid, + '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, + 'State' => ['Status' => 'running', 'Health' => ['Status' => 'healthy']], + 'RestartCount' => 0, + 'Config' => ['Labels' => [ + 'coolify.managed' => 'true', + 'coolify.applicationId' => '999999', + 'coolify.pullRequestId' => '0', + 'com.docker.compose.project' => $this->application->uuid, + 'com.docker.compose.project.config_files' => '/artifacts/x/docker-compose.yaml', + 'com.docker.compose.service' => $this->application->uuid, + ]], + ]; + + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + GetContainersStatus::run($this->server->fresh(), collect([$container]), collect()); + + expect($this->application->fresh()->status)->toStartWith('running'); + }); +}); + +describe('orphaned preview cleanup', function () { + test('keeps a transferred preview container whose numeric id belongs to nothing here', function () { + ApplicationPreview::forceCreate([ + 'application_id' => $this->application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://example.com/pr/7', + ]); + $job = new CleanupOrphanedPreviewContainersJob; + $isOrphaned = fn (array $container) => (fn () => $this->isOrphanedContainer($container))->call($job); + + expect($isOrphaned(['Labels' => "coolify.applicationId=999999,coolify.pullRequestId=7,com.docker.compose.project={$this->application->uuid}"]))->toBeFalse() + ->and($isOrphaned(['Labels' => "coolify.applicationUuid={$this->application->uuid},coolify.pullRequestId=7"]))->toBeFalse() + ->and($isOrphaned(['Labels' => "coolify.applicationUuid={$this->application->uuid},coolify.pullRequestId=8"]))->toBeTrue() + // Containers from before mid-2024: another compose project, so the local id is used, as before. + ->and($isOrphaned(['Labels' => "coolify.applicationId={$this->application->id},coolify.pullRequestId=7,com.docker.compose.project=deployment-uuid"]))->toBeFalse() + ->and($isOrphaned(['Labels' => 'coolify.applicationId=999999,coolify.pullRequestId=7']))->toBeTrue(); + }); +}); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index e936cf670b..beb96cb5cd 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -145,7 +145,11 @@ it('removes service containers before its volumes and local metadata', function $commandList = $commands->implode("\n"); expect($commandList) + ->toContain("label=coolify.serviceUuid={$service->uuid}") + ->toContain("label=com.docker.compose.project={$service->uuid}") + // Containers from before mid-2024 may have another compose project; the local id still finds them. ->toContain("label=coolify.serviceId={$service->id}") + ->toContain('sort -u') ->toContain('docker rm -f $container_ids') ->toContain("docker volume rm -f '{$service->uuid}_web-data'") ->and(strpos($commandList, 'docker rm -f $container_ids')) @@ -178,9 +182,12 @@ it('targets a service subresource container by its Docker labels', function () { app(DeleteService::class)->removeSubresourceContainer($application); expect($commands->implode("\n")) - ->toContain("label=coolify.serviceId={$service->id}") - ->toContain("label=coolify.service.subId={$application->id}") + ->toContain("label=coolify.serviceUuid={$service->uuid}") + ->toContain("label=coolify.service.subUuid={$application->uuid}") ->toContain('label=coolify.service.subType=application') + // Containers from before the UUID labels are found by compose project and service key. + ->toContain("label=com.docker.compose.project={$service->uuid}") + ->toContain('label=com.docker.compose.service=web') ->toContain('docker rm -f $container_ids'); }); @@ -208,8 +215,9 @@ it('removes only the database container when an application of the same service app(DeleteService::class)->removeSubresourceContainer($database->fresh()); expect($commands->implode("\n")) - ->toContain("label=coolify.service.subId={$application->id}") + ->toContain("label=coolify.service.subUuid={$database->uuid}") ->toContain('label=coolify.service.subType=database') + ->not->toContain("label=coolify.service.subUuid={$application->uuid}") ->not->toContain('label=coolify.service.subType=application'); }); @@ -297,6 +305,6 @@ it('removes service containers with a command that works for non-root SSH users' expect($commands->implode("\n")) ->not->toContain('sudo container_ids=') ->toContain("sudo bash -c 'sh -c") - ->toContain("label=coolify.service.subId={$application->id}") + ->toContain("label=coolify.service.subUuid={$application->uuid}") ->toContain('docker rm -f $container_ids'); }); diff --git a/tests/Feature/Jobs/PushServerUpdateJobTest.php b/tests/Feature/Jobs/PushServerUpdateJobTest.php index af667d023e..1fb7ce74e4 100644 --- a/tests/Feature/Jobs/PushServerUpdateJobTest.php +++ b/tests/Feature/Jobs/PushServerUpdateJobTest.php @@ -47,7 +47,7 @@ test('containers with empty service subId are skipped', function () { expect($job->serviceContainerStatuses)->toBeEmpty(); }); -test('containers with valid service subId are processed', function () { +test('service containers are matched by their UUID labels', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); $service = Service::factory()->create([ @@ -67,9 +67,9 @@ test('containers with valid service subId are processed', function () { 'health_status' => 'healthy', 'labels' => [ 'coolify.managed' => true, - 'coolify.serviceId' => (string) $service->id, + 'coolify.serviceUuid' => $service->uuid, 'coolify.service.subType' => 'application', - 'coolify.service.subId' => (string) $serviceApp->id, + 'coolify.service.subUuid' => $serviceApp->uuid, 'com.docker.compose.service' => 'myapp', ], ], @@ -81,3 +81,41 @@ test('containers with valid service subId are processed', function () { expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id); }); + +test('legacy service containers from another instance are matched by compose project, not by their numeric ids', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + ]); + $serviceApp = ServiceApplication::create([ + 'service_id' => $service->id, + 'uuid' => (string) str()->uuid(), + 'name' => 'web', + ]); + + // Ids from the source instance: they point to nothing, or to other resources, on this instance. + $data = [ + 'containers' => [ + [ + 'name' => 'web-'.$service->uuid, + 'state' => 'running', + 'health_status' => 'healthy', + 'labels' => [ + 'coolify.managed' => true, + 'coolify.serviceId' => (string) ($service->id + 1000), + 'coolify.service.subType' => 'application', + 'coolify.service.subId' => (string) ($serviceApp->id + 1000), + 'com.docker.compose.project' => $service->uuid, + 'com.docker.compose.service' => 'web', + ], + ], + ], + ]; + + $job = new PushServerUpdateJob($server, $data); + $job->handle(); + + expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id) + ->and($job->serviceContainerStatuses->keys()->all())->toBe(["{$service->id}:application:{$serviceApp->id}"]); +});