mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 12:23:48 +00:00
Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
398 lines
15 KiB
Plaintext
398 lines
15 KiB
Plaintext
<?php
|
|
|
|
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
|
use App\Events\V5CanvasResourceUpdated;
|
|
use App\Events\V5ClusterUpdated;
|
|
use App\Jobs\V5ReconcileServersJob;
|
|
use App\Jobs\V5ReconcileServerStateJob;
|
|
use App\Models\V5\Application as V5Application;
|
|
use App\Models\V5\ContainerStatus;
|
|
use App\Models\V5\Server as V5Server;
|
|
use App\Services\Flux\FluxClient;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Tests\Support\V5TestSchema;
|
|
|
|
beforeEach(function () {
|
|
Config::set('broadcasting.default', 'log');
|
|
Config::set('cache.default', 'array');
|
|
|
|
Schema::dropIfExists('v5_applications');
|
|
Schema::dropIfExists('v5_container_statuses');
|
|
Schema::dropIfExists('v5_servers');
|
|
});
|
|
|
|
function createReconcileV5Tables(): void
|
|
{
|
|
V5TestSchema::createServersTable();
|
|
V5TestSchema::createContainerStatusesTable();
|
|
V5TestSchema::createApplicationsTable();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function createReconcileServer(array $attributes = []): V5Server
|
|
{
|
|
return V5Server::query()->create([
|
|
'team_id' => 1,
|
|
'created_by_user_id' => 1,
|
|
'name' => 'edge-01',
|
|
'host' => '203.0.113.10',
|
|
'ssh_user' => 'root',
|
|
'ssh_port' => 22,
|
|
'status' => 'installed',
|
|
'capabilities' => ['coold'],
|
|
'wireguard_management_ip' => '100.64.0.10',
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function createReconcileApplication(V5Server $server, array $attributes = []): V5Application
|
|
{
|
|
return V5Application::query()->create([
|
|
'team_id' => $server->team_id,
|
|
'project_id' => 1,
|
|
'environment_id' => 1,
|
|
'server_id' => $server->id,
|
|
'created_by_user_id' => 1,
|
|
'name' => 'nginx-test',
|
|
'image' => 'docker.io/library/nginx:alpine',
|
|
'container_name' => 'coolify-v5-nginx-test',
|
|
'status' => 'running',
|
|
'runtime_container_id' => 'container-live',
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
it('marks a stale running application exited when its container is gone', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->with($server->fluxHostId())->andReturn([]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh())
|
|
->status->toBe('exited')
|
|
->status_message->toBe('Container not found on server during reconcile.');
|
|
expect($server->refresh())
|
|
->status->toBe('installed')
|
|
->last_status_check->toBe('reconcile')
|
|
->last_status_checked_at->not->toBeNull();
|
|
|
|
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
|
|
});
|
|
|
|
it('updates application status from the observed container state and refreshes container statuses', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, ['runtime_container_id' => null]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
|
[
|
|
'id' => 'container-live',
|
|
'name' => 'coolify-v5-nginx-test',
|
|
'image' => 'docker.io/library/nginx:alpine',
|
|
'state' => 'Restarting',
|
|
],
|
|
]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh())
|
|
->status->toBe('restarting')
|
|
->runtime_container_id->toBe('container-live');
|
|
|
|
$containerStatus = ContainerStatus::query()
|
|
->where('server_id', $server->id)
|
|
->where('container_id', 'container-live')
|
|
->first();
|
|
|
|
expect($containerStatus)->not->toBeNull()
|
|
->and($containerStatus->status)->toBe('restarting')
|
|
->and($containerStatus->last_seen_at)->not->toBeNull();
|
|
});
|
|
|
|
it('leaves a creating application without a container id alone during reconcile', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, [
|
|
'status' => 'creating',
|
|
'runtime_container_id' => null,
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh())->status->toBe('creating');
|
|
});
|
|
|
|
it('marks an installed server unreachable when the node cannot be reached', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('Flux did not return a response before the timeout.'));
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($server->refresh())
|
|
->status->toBe('unreachable')
|
|
->last_status_check->toBe('reconcile')
|
|
->last_status_output->toContain('Flux did not return a response');
|
|
expect($application->refresh())->status->toBe('running');
|
|
|
|
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
|
|
});
|
|
|
|
it('does not mark a failed server unreachable when the node cannot be reached', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer(['status' => 'failed']);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('boom'));
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($server->refresh())
|
|
->status->toBe('failed')
|
|
->last_status_check->toBe('reconcile');
|
|
});
|
|
|
|
it('restores an unreachable server to installed on a successful reconcile', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer(['status' => 'unreachable']);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($server->refresh())
|
|
->status->toBe('installed')
|
|
->last_status_check->toBe('reconcile');
|
|
|
|
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
|
|
});
|
|
|
|
it('dispatches per-server reconcile jobs only for managed servers', function () {
|
|
createReconcileV5Tables();
|
|
Queue::fake();
|
|
|
|
$managed = createReconcileServer(['host' => '203.0.113.10', 'wireguard_management_ip' => '100.64.0.10']);
|
|
$unreachable = createReconcileServer([
|
|
'name' => 'edge-02',
|
|
'host' => '203.0.113.11',
|
|
'wireguard_management_ip' => '100.64.0.11',
|
|
'status' => 'unreachable',
|
|
]);
|
|
$withoutCoold = createReconcileServer([
|
|
'name' => 'edge-03',
|
|
'host' => '203.0.113.12',
|
|
'wireguard_management_ip' => '100.64.0.12',
|
|
'capabilities' => [],
|
|
]);
|
|
$notInstalled = createReconcileServer([
|
|
'name' => 'edge-04',
|
|
'host' => '203.0.113.13',
|
|
'wireguard_management_ip' => '100.64.0.13',
|
|
'status' => 'added',
|
|
]);
|
|
|
|
(new V5ReconcileServersJob)->handle();
|
|
|
|
Queue::assertPushed(V5ReconcileServerStateJob::class, 2);
|
|
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $managed->id);
|
|
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $unreachable->id);
|
|
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $withoutCoold->id);
|
|
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $notInstalled->id);
|
|
});
|
|
|
|
it('drops a reconcile write when the stored observation is newer than the snapshot', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, [
|
|
'status' => 'running',
|
|
'runtime_container_id' => 'container-live',
|
|
// A fresher webhook already advanced the watermark past this pull.
|
|
'status_observed_at' => now()->addMinutes(5),
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
|
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'exited'],
|
|
]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh())
|
|
->status->toBe('running')
|
|
->status_observed_at->not->toBeNull();
|
|
|
|
Event::assertNotDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
|
|
});
|
|
|
|
it('advances status_observed_at when reconcile writes an application status', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, [
|
|
'status' => 'running',
|
|
'runtime_container_id' => 'container-live',
|
|
'status_observed_at' => null,
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
|
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'restarting'],
|
|
]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh())
|
|
->status->toBe('restarting')
|
|
->status_observed_at->not->toBeNull();
|
|
});
|
|
|
|
it('normalizes the coold configured state to a valid status on reconcile', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
|
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'configured'],
|
|
]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh()->status)->toBe('configured');
|
|
|
|
$containerStatus = ContainerStatus::query()
|
|
->where('server_id', $server->id)
|
|
->where('container_id', 'container-live')
|
|
->first();
|
|
|
|
expect($containerStatus)->not->toBeNull()
|
|
->and($containerStatus->status)->toBe('configured');
|
|
});
|
|
|
|
it('normalizes the coold configured state on the flux webhook path', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
|
|
|
|
ApplyFluxResourceStatusUpdate::run([
|
|
'resource_type' => 'application',
|
|
'server_uuid' => $server->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'status' => 'configured',
|
|
'runtime_container_id' => 'container-live',
|
|
]);
|
|
|
|
expect($application->refresh()->status)->toBe('configured');
|
|
});
|
|
|
|
it('does not broadcast when reconcile finds the application status unchanged', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
|
|
$server = createReconcileServer();
|
|
$application = createReconcileApplication($server, [
|
|
'status' => 'running',
|
|
'runtime_container_id' => 'container-live',
|
|
]);
|
|
|
|
$fluxClient = Mockery::mock(FluxClient::class);
|
|
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
|
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'running'],
|
|
]);
|
|
|
|
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
|
|
|
expect($application->refresh()->status)->toBe('running');
|
|
|
|
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
|
});
|
|
|
|
it('runs the reconcile jobs on the dedicated v5-reconcile queue', function () {
|
|
expect((new V5ReconcileServersJob)->queue)->toBe('v5-reconcile')
|
|
->and((new V5ReconcileServerStateJob(1))->queue)->toBe('v5-reconcile');
|
|
});
|
|
|
|
it('prunes stale and orphaned container statuses but keeps fresh and live rows', function () {
|
|
createReconcileV5Tables();
|
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
|
Queue::fake();
|
|
|
|
$server = createReconcileServer();
|
|
createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
|
|
|
|
$staleRow = ContainerStatus::query()->create([
|
|
'team_id' => 1,
|
|
'server_id' => $server->id,
|
|
'container_id' => 'container-stale',
|
|
'status' => 'running',
|
|
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
|
|
]);
|
|
$staleButLiveRow = ContainerStatus::query()->create([
|
|
'team_id' => 1,
|
|
'server_id' => $server->id,
|
|
'container_id' => 'container-live',
|
|
'status' => 'running',
|
|
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
|
|
]);
|
|
$freshRow = ContainerStatus::query()->create([
|
|
'team_id' => 1,
|
|
'server_id' => $server->id,
|
|
'container_id' => 'container-fresh',
|
|
'status' => 'running',
|
|
'last_seen_at' => now()->subHour(),
|
|
]);
|
|
$orphanedServerRow = ContainerStatus::query()->create([
|
|
'team_id' => 1,
|
|
'server_id' => $server->id + 999,
|
|
'container_id' => 'container-orphaned',
|
|
'status' => 'running',
|
|
'last_seen_at' => now()->subHour(),
|
|
]);
|
|
|
|
(new V5ReconcileServersJob)->handle();
|
|
|
|
expect(ContainerStatus::query()->find($staleRow->id))->toBeNull()
|
|
->and(ContainerStatus::query()->find($orphanedServerRow->id))->toBeNull()
|
|
->and(ContainerStatus::query()->find($staleButLiveRow->id))->not->toBeNull()
|
|
->and(ContainerStatus::query()->find($freshRow->id))->not->toBeNull();
|
|
});
|