mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -157,7 +157,7 @@ class CleanupPreviewDeployment
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus(
|
||||
$server,
|
||||
$application->id,
|
||||
$application,
|
||||
$pull_request_id
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
@@ -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', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class InternalAccess extends Component
|
||||
try {
|
||||
$containers = getCurrentApplicationContainerStatus(
|
||||
$this->application->destination->server,
|
||||
$this->application->id,
|
||||
$this->application,
|
||||
0
|
||||
);
|
||||
$currentContainer = $containers->first(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
+192
-19
@@ -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<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([]);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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}"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user