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
- `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
);
+2 -2
View File
@@ -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);
+20 -21
View File
@@ -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";
}
+7 -6
View File
@@ -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);
}
+3 -1
View File
@@ -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) {
+5 -5
View File
@@ -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', ''));
}
/**
+1 -1
View File
@@ -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) {
+14 -9
View File
@@ -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
+4 -4
View File
@@ -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
),
};
+1 -1
View File
@@ -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.');
}
+4 -4
View File
@@ -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
View File
@@ -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));
}
+3 -3
View File
@@ -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,
);
+3 -3
View File
@@ -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');
});
+41 -3
View File
@@ -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}"]);
});