mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Numeric coolify.{type}Id labels can collide with resources on another
Coolify instance managing the same server, so they no longer select or
resolve container owners. Drop the legacyId fallback from container
lookups in DeleteService and ComplexStatusCheck.
Legacy application containers without a UUID label are now resolved by
their compose service name, which starts with the application UUID.
286 lines
16 KiB
PHP
286 lines
16 KiB
PHP
<?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 and the compose project, never a numeric id', 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}")
|
|
->not->toContain("label=coolify.databaseId={$database->id}")
|
|
->toContain("label=coolify.serviceUuid={$service->uuid}")
|
|
->toContain("label=com.docker.compose.project={$service->uuid}")
|
|
->not->toContain("label=coolify.serviceId={$service->id}");
|
|
});
|
|
|
|
test('service part containers are matched by UUID label, then compose service name', 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)
|
|
// Another instance's container with the same numeric ids: not ours.
|
|
->and($partOf(['coolify.serviceId' => (string) $service->id, 'coolify.service.subType' => 'application', 'coolify.service.subId' => (string) $part->id, 'com.docker.compose.project' => 'other']))->toBeNull();
|
|
});
|
|
});
|
|
|
|
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('a local numeric id never makes another instance\'s container ours', function () {
|
|
$applications = collect([$this->application]);
|
|
$localId = (string) $this->application->id;
|
|
|
|
expect(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'other-instance-uuid'], 'application'))->toBeNull()
|
|
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId], 'application'))->toBeNull()
|
|
->and(resolveContainerOwner($applications, ['coolify.applicationUuid' => 'unknown', 'coolify.applicationId' => $localId], 'application'))->toBeNull()
|
|
->and(resolveContainerApplicationId($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'other-instance-uuid']))->toBeNull()
|
|
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => $this->application->uuid], 'application')?->id)->toBe($this->application->id)
|
|
// Deployed before mid-2024: compose project was the deployment directory, the compose service starts with the UUID.
|
|
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => $this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
|
|
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'other-instance-uuid-104512123456'], 'application'))->toBeNull()
|
|
->and(implode("\n", dockerPsByOwnerCommands('application', 'app-uuid')))->not->toContain('label=coolify.applicationId=');
|
|
});
|
|
|
|
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(3)
|
|
->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'")
|
|
// Deployed before mid-2024: the container name starts with the application UUID.
|
|
->and($commands[2])->toContain("--filter 'label=coolify.applicationId'")
|
|
->toContain("--filter 'name=^app-uuid'")
|
|
->toContain("--filter 'label=coolify.pullRequestId=0'")
|
|
->and(dockerPsByOwnerCommands('service', 'service-uuid'))->toHaveCount(2);
|
|
});
|
|
});
|
|
|
|
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 the UUID in their name', 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()
|
|
// Another instance's preview container with the same numeric id: not ours, so never removed.
|
|
->and($isOrphaned(['Labels' => "coolify.applicationId={$this->application->id},coolify.pullRequestId=8,com.docker.compose.project=other-instance-uuid"]))->toBeFalse()
|
|
->and($isOrphaned(['Labels' => 'coolify.applicationId=999999,coolify.pullRequestId=7']))->toBeFalse();
|
|
});
|
|
});
|