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.
This commit is contained in:
Andras Bacsai
2026-09-29 09:57:56 +02:00
parent abf9915995
commit af983a29d8
27 changed files with 604 additions and 102 deletions
+5
View File
@@ -78,3 +78,8 @@
## Format only your own files ## 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. - `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.
@@ -157,7 +157,7 @@ class CleanupPreviewDeployment
} else { } else {
$containers = getCurrentApplicationContainerStatus( $containers = getCurrentApplicationContainerStatus(
$server, $server,
$application->id, $application,
$pull_request_id $pull_request_id
); );
+2 -2
View File
@@ -37,8 +37,8 @@ class StopApplication
} }
$containers = $previewDeployments $containers = $previewDeployments
? getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true) ? getCurrentApplicationContainerStatus($server, $application, includePullrequests: true)
: getCurrentApplicationContainerStatus($server, $application->id, 0); : getCurrentApplicationContainerStatus($server, $application, 0);
$containersToStop = $containers->pluck('Names')->toArray(); $containersToStop = $containers->pluck('Names')->toArray();
$timeout = $application->settings->stopGracePeriodSeconds(); $timeout = $application->settings->stopGracePeriodSeconds();
@@ -19,7 +19,7 @@ class StopApplicationOneServer
return 'Server is not functional'; return 'Server is not functional';
} }
try { try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0); $containers = getCurrentApplicationContainerStatus($server, $application, 0);
$timeout = $application->settings->stopGracePeriodSeconds(); $timeout = $application->settings->stopGracePeriodSeconds();
if ($containers->count() > 0) { if ($containers->count() > 0) {
@@ -14,7 +14,7 @@ class StopApplicationPreview
{ {
$application = $preview->application; $application = $preview->application;
$server = $application->destination->server; $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) { foreach ($containers->pluck('Names') as $containerName) {
$escapedContainerName = escapeshellarg($containerName); $escapedContainerName = escapeshellarg($containerName);
+20 -21
View File
@@ -124,18 +124,22 @@ class GetContainersStatus
$healthSuffix = $containerHealth ?? 'unknown'; $healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "$containerStatus:$healthSuffix"; $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)) { if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
continue; continue;
} }
$applicationId = data_get($labels, 'coolify.applicationId'); // Containers are matched by owner UUID; numeric ids differ between instances.
if ($applicationId) { $isApplicationContainer = isContainerOfType($flatLabels, 'application');
$isServiceContainer = isContainerOfType($flatLabels, 'service');
[$parentService, $serviceSubType, $serviceSubResource] = resolveServiceContainerOwner($services, $flatLabels);
if ($isApplicationContainer) {
$pullRequestId = data_get($labels, 'coolify.pullRequestId'); $pullRequestId = data_get($labels, 'coolify.pullRequestId');
if ($pullRequestId) { if ($pullRequestId) {
if (str($applicationId)->contains('-')) { $applicationId = resolveContainerApplicationId($this->applications, $flatLabels);
$applicationId = str($applicationId)->before('-'); $preview = $applicationId
} ? ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first()
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first(); : null;
if ($preview) { if ($preview) {
$foundApplicationPreviews[] = $preview->id; $foundApplicationPreviews[] = $preview->id;
$statusFromDb = $preview->status; $statusFromDb = $preview->status;
@@ -154,7 +158,8 @@ class GetContainersStatus
// Notify user that this container should not be there. // Notify user that this container should not be there.
} }
} else { } else {
$application = $this->applications->where('id', $applicationId)->first(); $application = resolveContainerOwner($this->applications, $flatLabels, 'application');
$applicationId = $application?->id;
if ($application) { if ($application) {
$foundApplications[] = $application->id; $foundApplications[] = $application->id;
if ($application->container_present !== true) { if ($application->container_present !== true) {
@@ -199,9 +204,8 @@ class GetContainersStatus
if ($uuid) { if ($uuid) {
if ($type === 'service') { if ($type === 'service') {
$database_id = data_get($labels, 'coolify.service.subId'); if ($serviceSubResource instanceof ServiceDatabase) {
if ($database_id) { $service_db = $serviceSubResource;
$service_db = ServiceDatabase::where('id', $database_id)->first();
if ($service_db) { if ($service_db) {
$proxyUuid = $service_db->uuid; $proxyUuid = $service_db->uuid;
$isPublic = data_get($service_db, 'is_public'); $isPublic = data_get($service_db, 'is_public');
@@ -289,14 +293,13 @@ class GetContainersStatus
$foundDatabases[] = 0; $foundDatabases[] = 0;
} }
} }
$serviceLabelId = data_get($labels, 'coolify.serviceId'); if ($isServiceContainer) {
if ($serviceLabelId) {
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$parentService = $services->where('id', $serviceLabelId)->first();
if (! $parentService) { if (! $parentService) {
continue; continue;
} }
$serviceLabelId = $parentService->id;
$subType = $serviceSubType;
$subId = $serviceSubResource?->id;
// Store container status for aggregation // Store container status for aggregation
if (! isset($this->serviceContainerStatuses)) { if (! isset($this->serviceContainerStatuses)) {
@@ -319,11 +322,7 @@ class GetContainersStatus
} }
// Mark service as found // Mark service as found
if ($subType === 'application') { $service = $serviceSubResource;
$service = $parentService->applications()->where('id', $subId)->first();
} else {
$service = $parentService->databases()->where('id', $subId)->first();
}
if ($service) { if ($service) {
$foundServices[] = "$service->id-$service->name"; $foundServices[] = "$service->id-$service->name";
} }
+7 -6
View File
@@ -56,18 +56,19 @@ class DeleteService
private function removeContainers(Service $service, ServiceApplication|ServiceDatabase|null $subresource = null): void private function removeContainers(Service $service, ServiceApplication|ServiceDatabase|null $subresource = null): void
{ {
$serviceId = (int) $service->id; $filters = [];
$filters = "--filter label=coolify.serviceId={$serviceId}"; $legacyFilters = [];
if ($subresource !== null) { if ($subresource !== null) {
// Applications and databases are separate tables, so an id alone can match the other type.
$subType = $subresource instanceof ServiceDatabase ? 'database' : 'application'; $subType = $subresource instanceof ServiceDatabase ? 'database' : 'application';
$subId = (int) $subresource->id; $filters = ["label=coolify.service.subUuid={$subresource->uuid}", "label=coolify.service.subType={$subType}"];
$filters .= " --filter label=coolify.service.subId={$subId} --filter 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 // 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. // 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); instant_remote_process(['sh -c '.escapeshellarg($script)], $service->server);
} }
+3 -1
View File
@@ -29,7 +29,9 @@ class ComplexStatusCheck
continue; 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); $containers = format_docker_command_output_to_json($containers);
if ($containers->count() > 0) { if ($containers->count() > 0) {
@@ -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) { if ($containers->count() == 0) {
return response()->json([ return response()->json([
@@ -2693,7 +2693,7 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Database not found.'], 404); 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) { if ($containers->count() == 0) {
return response()->json([ return response()->json([
@@ -966,7 +966,7 @@ class ServicesController extends Controller
} }
$name = "{$subServiceName}-{$service->uuid}"; $name = "{$subServiceName}-{$service->uuid}";
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service, $name);
$container = $containers->first(); $container = $containers->first();
if (! $container) { if (! $container) {
+5 -5
View File
@@ -3759,7 +3759,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return escapeDollarSign($value); 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 // Check for custom HEALTHCHECK
if ($this->application->build_pack === 'dockerfile' || $this->application->dockerfile) { 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.'); $this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) { if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled) { 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->containerNamesToRemove($containers)->each(function (string $containerName) {
$this->graceful_shutdown_container($containerName); $this->graceful_shutdown_container($containerName);
}); });
} else { } 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) { if ($this->pull_request_id === 0) {
$containers = $containers->filter(function ($container) { $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); 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)) { if (empty($this->application->pre_deployment_command)) {
return; 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) { if ($containers->count() == 0) {
$this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.'); $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('----------------------------------------');
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).'); $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) { if ($containers->count() == 0) {
$this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.'); $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
@@ -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 private function extractApplicationId($container): ?int
{ {
$labels = data_get($container, 'Labels', ''); return resolveContainerApplicationId(collect(), (string) data_get($container, 'Labels', ''));
if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) {
return (int) $matches[1];
}
return null;
} }
/** /**
+1 -1
View File
@@ -264,7 +264,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
instant_remote_process(["docker stack rm {$escapedStackName}"], $server); instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
} else { } else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray(); $containers = getCurrentApplicationContainerStatus($server, $application, $pull_request_id)->toArray();
$this->stopPreviewContainers($containers, $server); $this->stopPreviewContainers($containers, $server);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
+14 -9
View File
@@ -257,8 +257,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true; $this->foundLogDrainContainer = true;
} }
if ($labels->has('coolify.applicationId')) { // Containers are matched by owner UUID; numeric ids differ between instances.
$applicationId = $labels->get('coolify.applicationId'); if (isContainerOfType($labels, 'application')) {
$applicationId = resolveContainerApplicationId($this->applications, $labels);
if ($applicationId === null) {
continue;
}
$applicationId = (string) $applicationId;
$pullRequestId = $labels->get('coolify.pullRequestId', '0'); $pullRequestId = $labels->get('coolify.pullRequestId', '0');
try { try {
if ($pullRequestId === '0') { if ($pullRequestId === '0') {
@@ -300,13 +305,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
} }
} catch (\Exception $e) { } catch (\Exception $e) {
} }
} elseif ($labels->has('coolify.serviceId')) { } elseif (isContainerOfType($labels, 'service')) {
$serviceId = $labels->get('coolify.serviceId'); [$service, $subType, $servicePart] = resolveServiceContainerOwner($this->services, $labels);
$subType = $labels->get('coolify.service.subType'); if (! $service || ! $servicePart) {
$subId = $labels->get('coolify.service.subId');
if (empty(trim((string) $subId))) {
continue; continue;
} }
$serviceId = (string) $service->id;
$subId = (string) $servicePart->id;
if ($subType === 'application') { if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId); $this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation // Store container status for aggregation
@@ -482,8 +487,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
'docker_compose_raw', 'docker_compose_raw',
]) ])
->with([ ->with([
'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', '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,service_id,status,last_online_at,is_public,name', 'databases:id,uuid,name,service_id,status,last_online_at,is_public',
]) ])
->get(); ->get();
} }
@@ -28,7 +28,7 @@ class InternalAccess extends Component
try { try {
$containers = getCurrentApplicationContainerStatus( $containers = getCurrentApplicationContainerStatus(
$this->application->destination->server, $this->application->destination->server,
$this->application->id, $this->application,
0 0
); );
$currentContainer = $containers->first( $currentContainer = $containers->first(
@@ -307,7 +307,7 @@ class Previews extends Component
if ($this->application->destination->server->isSwarm()) { if ($this->application->destination->server->isSwarm()) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
} else { } else {
$containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); $containers = getCurrentApplicationContainerStatus($server, $this->application, $pull_request_id)->toArray();
$this->stopContainers($containers, $server); $this->stopContainers($containers, $server);
} }
@@ -100,7 +100,7 @@ class ExecuteContainerCommand extends Component
], ],
]); ]);
} else { } else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true); $containers = getCurrentApplicationContainerStatus($server, $this->resource, includePullrequests: true);
} }
foreach ($containers as $container) { foreach ($containers as $container) {
// if container state is running // if container state is running
+4 -4
View File
@@ -88,20 +88,20 @@ class Logs extends Component
} }
// Docker labels differ by resource type: // 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) { $containers = match (true) {
$this->resource instanceof Application => getCurrentApplicationContainerStatus( $this->resource instanceof Application => getCurrentApplicationContainerStatus(
$server, $server,
$this->resource->id, $this->resource,
includePullrequests: true includePullrequests: true
), ),
$this->resource instanceof Service => getCurrentServiceContainerStatus( $this->resource instanceof Service => getCurrentServiceContainerStatus(
$server, $server,
$this->resource->id $this->resource
), ),
default => getCurrentDatabaseContainerStatus( default => getCurrentDatabaseContainerStatus(
$server, $server,
$this->resource->id $this->resource
), ),
}; };
+1 -1
View File
@@ -223,7 +223,7 @@ class GetLogs extends Tool
if (! $server) { if (! $server) {
throw new \RuntimeException('Application has no server destination.'); throw new \RuntimeException('Application has no server destination.');
} }
$containers = getCurrentApplicationContainerStatus($server, $resource->id); $containers = getCurrentApplicationContainerStatus($server, $resource);
if ($containers->count() === 0) { if ($containers->count() === 0) {
throw new \RuntimeException('Application has no running containers.'); throw new \RuntimeException('Application has no running containers.');
} }
+4 -4
View File
@@ -530,8 +530,8 @@ class Application extends BaseModel
public function getContainersToStop(Server $server, bool $previewDeployments = false): array public function getContainersToStop(Server $server, bool $previewDeployments = false): array
{ {
$containers = $previewDeployments $containers = $previewDeployments
? getCurrentApplicationContainerStatus($server, $this->id, includePullrequests: true) ? getCurrentApplicationContainerStatus($server, $this, includePullrequests: true)
: getCurrentApplicationContainerStatus($server, $this->id, 0); : getCurrentApplicationContainerStatus($server, $this, 0);
return $containers->pluck('Names')->toArray(); return $containers->pluck('Names')->toArray();
} }
@@ -2159,8 +2159,8 @@ class Application extends BaseModel
if (! $labels->contains('coolify.managed')) { if (! $labels->contains('coolify.managed')) {
$labels->push('coolify.managed=true'); $labels->push('coolify.managed=true');
} }
if (! $labels->contains('coolify.applicationId')) { if (! $labels->contains('coolify.applicationUuid='.$this->uuid)) {
$labels->push('coolify.applicationId='.$this->id); $labels->push('coolify.applicationUuid='.$this->uuid);
} }
if (! $labels->contains('coolify.type')) { if (! $labels->contains('coolify.type')) {
$labels->push('coolify.type=application'); $labels->push('coolify.type=application');
+192 -19
View File
@@ -4,8 +4,11 @@ use App\Enums\ProxyTypes;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; 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); 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<int, TResource> $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<int, Service> $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<string> $extraFilters Additional `--filter` values, e.g. `label=coolify.pullRequestId=0`.
* @param list<string>|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<string>
*/
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<string> $extraFilters
* @param list<string>|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([]); $containers = collect([]);
if (! $server->isSwarm()) { if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server); $containers = containersOwnedBy($server, 'application', $application);
$containers = format_docker_command_output_to_json($containers);
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) { $containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
$labels = data_get($container, 'Labels'); $labels = data_get($container, 'Labels');
@@ -105,35 +284,29 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
return $containers; return $containers;
} }
function getCurrentServiceContainerStatus(Server $server, int $id): Collection function getCurrentServiceContainerStatus(Server $server, Service $service): Collection
{ {
$containers = collect([]); $containers = collect([]);
if (! $server->isSwarm()) { if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server); return containersOwnedBy($server, 'service', $service);
$containers = format_docker_command_output_to_json($containers);
return $containers->filter();
} }
return $containers; return $containers;
} }
function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection function getCurrentDatabaseContainerStatus(Server $server, Model $database): Collection
{ {
$containers = collect([]); $containers = collect([]);
if (! $server->isSwarm()) { if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); return containersOwnedBy($server, 'database', $database);
$containers = format_docker_command_output_to_json($containers);
return $containers->filter();
} }
return $containers; 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 function filterServiceSubContainersByName(Collection $containers, string $name): Collection
@@ -411,7 +584,7 @@ function defaultDatabaseLabels($database)
$labels = collect([]); $labels = collect([]);
$labels->push('coolify.managed=true'); $labels->push('coolify.managed=true');
$labels->push('coolify.type=database'); $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.resourceName='.Str::slug($database->name));
$labels->push('coolify.serviceName='.Str::slug($database->name)); $labels->push('coolify.serviceName='.Str::slug($database->name));
$labels->push('coolify.projectName='.Str::slug($database->project()->name)); $labels->push('coolify.projectName='.Str::slug($database->project()->name));
@@ -421,12 +594,12 @@ function defaultDatabaseLabels($database)
return $labels; 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 = collect([]);
$labels->push('coolify.managed=true'); $labels->push('coolify.managed=true');
$labels->push('coolify.version='.config('constants.coolify.version')); $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.type=$type");
$labels->push('coolify.name='.Str::slug($name)); $labels->push('coolify.name='.Str::slug($name));
$labels->push('coolify.resourceName='.Str::slug($resourceName)); $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); $labels->push('coolify.pullRequestId='.$pull_request_id);
if ($type === 'service') { if ($type === 'service') {
$subId && $labels->push('coolify.service.subId='.$subId); $subUuid && $labels->push('coolify.service.subUuid='.$subUuid);
$subType && $labels->push('coolify.service.subType='.$subType); $subType && $labels->push('coolify.service.subType='.$subType);
$subName && $labels->push('coolify.service.subName='.Str::slug($subName)); $subName && $labels->push('coolify.service.subName='.Str::slug($subName));
} }
+3 -3
View File
@@ -1835,7 +1835,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} }
} }
$defaultLabels = defaultLabels( $defaultLabels = defaultLabels(
id: $resource->id, uuid: $resource->uuid,
name: $containerName, name: $containerName,
projectName: $resource->project()->name, projectName: $resource->project()->name,
resourceName: $resource->name, resourceName: $resource->name,
@@ -3098,13 +3098,13 @@ function serviceParser(Service $resource): Collection
: collect([]); : collect([]);
$defaultLabels = defaultLabels( $defaultLabels = defaultLabels(
id: $resource->id, uuid: $resource->uuid,
name: $containerName, name: $containerName,
projectName: $resource->project()->name, projectName: $resource->project()->name,
resourceName: $resource->name, resourceName: $resource->name,
type: 'service', type: 'service',
subType: $savedService instanceof ServiceDatabase ? 'database' : 'application', subType: $savedService instanceof ServiceDatabase ? 'database' : 'application',
subId: $savedService->id, subUuid: $savedService->uuid,
subName: $savedService->human_name ?? $savedService->name, subName: $savedService->human_name ?? $savedService->name,
environment: $resource->environment->name, environment: $resource->environment->name,
); );
+3 -3
View File
@@ -3193,13 +3193,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
? $savedService->noindexDomains() ? $savedService->noindexDomains()
: collect([]); : collect([]);
$defaultLabels = defaultLabels( $defaultLabels = defaultLabels(
id: $resource->id, uuid: $resource->uuid,
name: $containerName, name: $containerName,
projectName: $resource->project()->name, projectName: $resource->project()->name,
resourceName: $resource->name, resourceName: $resource->name,
type: 'service', type: 'service',
subType: $isDatabase ? 'database' : 'application', subType: $isDatabase ? 'database' : 'application',
subId: $savedService->id, subUuid: $savedService->uuid,
subName: $savedService->name, subName: $savedService->name,
environment: $resource->environment->name, environment: $resource->environment->name,
); );
@@ -4122,7 +4122,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
} }
$defaultLabels = defaultLabels( $defaultLabels = defaultLabels(
id: $resource->id, uuid: $resource->uuid,
name: $containerName, name: $containerName,
projectName: $resource->project()->name, projectName: $resource->project()->name,
resourceName: $resource->name, resourceName: $resource->name,
@@ -0,0 +1,276 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\PushServerUpdateJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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();
});
});
@@ -145,7 +145,11 @@ it('removes service containers before its volumes and local metadata', function
$commandList = $commands->implode("\n"); $commandList = $commands->implode("\n");
expect($commandList) 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("label=coolify.serviceId={$service->id}")
->toContain('sort -u')
->toContain('docker rm -f $container_ids') ->toContain('docker rm -f $container_ids')
->toContain("docker volume rm -f '{$service->uuid}_web-data'") ->toContain("docker volume rm -f '{$service->uuid}_web-data'")
->and(strpos($commandList, 'docker rm -f $container_ids')) ->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); app(DeleteService::class)->removeSubresourceContainer($application);
expect($commands->implode("\n")) expect($commands->implode("\n"))
->toContain("label=coolify.serviceId={$service->id}") ->toContain("label=coolify.serviceUuid={$service->uuid}")
->toContain("label=coolify.service.subId={$application->id}") ->toContain("label=coolify.service.subUuid={$application->uuid}")
->toContain('label=coolify.service.subType=application') ->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'); ->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()); app(DeleteService::class)->removeSubresourceContainer($database->fresh());
expect($commands->implode("\n")) expect($commands->implode("\n"))
->toContain("label=coolify.service.subId={$application->id}") ->toContain("label=coolify.service.subUuid={$database->uuid}")
->toContain('label=coolify.service.subType=database') ->toContain('label=coolify.service.subType=database')
->not->toContain("label=coolify.service.subUuid={$application->uuid}")
->not->toContain('label=coolify.service.subType=application'); ->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")) expect($commands->implode("\n"))
->not->toContain('sudo container_ids=') ->not->toContain('sudo container_ids=')
->toContain("sudo bash -c 'sh -c") ->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'); ->toContain('docker rm -f $container_ids');
}); });
+41 -3
View File
@@ -47,7 +47,7 @@ test('containers with empty service subId are skipped', function () {
expect($job->serviceContainerStatuses)->toBeEmpty(); 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(); $team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]); $server = Server::factory()->create(['team_id' => $team->id]);
$service = Service::factory()->create([ $service = Service::factory()->create([
@@ -67,9 +67,9 @@ test('containers with valid service subId are processed', function () {
'health_status' => 'healthy', 'health_status' => 'healthy',
'labels' => [ 'labels' => [
'coolify.managed' => true, 'coolify.managed' => true,
'coolify.serviceId' => (string) $service->id, 'coolify.serviceUuid' => $service->uuid,
'coolify.service.subType' => 'application', 'coolify.service.subType' => 'application',
'coolify.service.subId' => (string) $serviceApp->id, 'coolify.service.subUuid' => $serviceApp->uuid,
'com.docker.compose.service' => 'myapp', '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); 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}"]);
});