mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Add UpdateNodeWorkloadConfiguration to create a new workload revision
when the command, ports or environment change. Revisions now store the
environment separately, and a shared NodeWorkload::createRevision helper
builds them. The cluster application page can edit these settings.
Deployments take a pull policy ('missing' or 'newer'), which is passed
through to Sentinel. User redeploys use 'newer' so mutable tags pull the
latest image. Before a move starts, the source node must support
workload.lifecycle.v1 and the target must support workload.deploy.v1.
The coordinated workload.move.v1 command no longer needs its own
capability. Adds a migration for Sentinel capabilities on nodes.
Adds a 'naked' dev QEMU profile runtime. These VMs boot with the
coolify-flux hosts entry and are not seeded, so they can be added
through the Node onboarding UI. Removes the standalone scripts/dev-qemu
script in favour of scripts/dev.
218 lines
9.2 KiB
PHP
218 lines
9.2 KiB
PHP
<?php
|
|
|
|
use App\Jobs\RefreshNodeContainersJob;
|
|
use App\Models\Node;
|
|
use App\Models\Server;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->set('app.env', 'local');
|
|
config()->set('constants.sentinel.host_enabled', true);
|
|
config()->set('constants.flux.internal_token', 'internal-secret');
|
|
config()->set('constants.flux.public_url', 'http://flux:7443');
|
|
config()->set('constants.flux.development_allow_plaintext', true);
|
|
$user = User::factory()->create();
|
|
$this->node = Node::factory()->create([
|
|
'team_id' => $user->teams()->firstOrFail()->id,
|
|
]);
|
|
});
|
|
|
|
it('records a bounded Flux connection observation', function () {
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'connected',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'sentinel_version' => 'main',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'plaintext',
|
|
'capabilities' => ['container.list.v1', 'workload.deploy.v1'],
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
expect(Cache::get("flux:connection:{$this->node->uuid}"))->toMatchArray([
|
|
'status' => 'connected',
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'plaintext',
|
|
'endpoint' => 'http://flux:7443',
|
|
'last_heartbeat_at' => now()->toIso8601String(),
|
|
'capabilities' => ['container.list.v1', 'workload.deploy.v1'],
|
|
])->and($this->node->refresh()->is_reachable)->toBeTrue();
|
|
});
|
|
|
|
it('restores a Node when a heartbeat arrives after it was unavailable', function () {
|
|
$this->node->update(['is_reachable' => false]);
|
|
Cache::put($this->node->cacheKey(), [
|
|
'status' => 'connected',
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
]);
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'heartbeat',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
expect($this->node->refresh()->is_reachable)->toBeTrue()
|
|
->and(Cache::get($this->node->cacheKey(), []))->toMatchArray([
|
|
'status' => 'connected',
|
|
'last_heartbeat_at' => now()->toIso8601String(),
|
|
]);
|
|
});
|
|
|
|
it('queues an immediate inventory refresh for a runtime change from the active connection', function () {
|
|
Queue::fake();
|
|
Cache::put($this->node->cacheKey(), [
|
|
'status' => 'connected',
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'last_heartbeat_at' => now()->toIso8601String(),
|
|
]);
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'runtime_changed',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'event_id' => 'runtime-1',
|
|
'observed_at_unix_ms' => now()->getTimestampMs(),
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
Queue::assertPushed(RefreshNodeContainersJob::class, fn ($job) => $job->nodeId === $this->node->id);
|
|
});
|
|
|
|
it('ignores a runtime change from an old connection', function () {
|
|
Queue::fake();
|
|
Cache::put($this->node->cacheKey(), [
|
|
'status' => 'connected',
|
|
'connection_id' => '22222222-2222-4222-8222-222222222222',
|
|
'last_heartbeat_at' => now()->toIso8601String(),
|
|
]);
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'runtime_changed',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'event_id' => 'runtime-old',
|
|
'observed_at_unix_ms' => now()->getTimestampMs(),
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
it('uses the derived TLS endpoint in connection observations', function () {
|
|
config()->set('constants.flux.public_url', null);
|
|
config()->set('constants.flux.port', 7443);
|
|
config()->set('app.url', 'https://coolify.example.com');
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'connected',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'sentinel_version' => 'main',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'tls',
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
expect(Cache::get("flux:connection:{$this->node->uuid}"))->toMatchArray([
|
|
'transport' => 'tls',
|
|
'endpoint' => 'https://coolify.example.com:7443',
|
|
]);
|
|
});
|
|
|
|
it('keeps connection state stable while Sentinel refreshes its credential', function () {
|
|
Carbon::setTestNow('2026-09-12 11:13:55');
|
|
$connectedPayload = [
|
|
'event' => 'connected',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'sentinel_version' => 'main',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'tls',
|
|
];
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', $connectedPayload, ['Authorization' => 'Bearer internal-secret'])
|
|
->assertNoContent();
|
|
|
|
foreach (['11:17:00', '11:21:00', '11:25:00', '11:27:54'] as $heartbeatTime) {
|
|
Carbon::setTestNow("2026-09-12 {$heartbeatTime}");
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'heartbeat',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => $connectedPayload['connection_id'],
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
}
|
|
|
|
Carbon::setTestNow('2026-09-12 11:27:55');
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'disconnected',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => $connectedPayload['connection_id'],
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
expect(Cache::get("flux:connection:{$this->node->uuid}"))->toMatchArray([
|
|
'status' => 'reconnecting',
|
|
'connected_at' => '2026-09-12T11:13:55+00:00',
|
|
])->and($this->node->refresh()->is_reachable)->toBeFalse();
|
|
|
|
Carbon::setTestNow('2026-09-12 11:27:56');
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
...$connectedPayload,
|
|
'connection_id' => '22222222-2222-4222-8222-222222222222',
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
expect(Cache::get("flux:connection:{$this->node->uuid}"))->toMatchArray([
|
|
'status' => 'connected',
|
|
'connection_id' => '22222222-2222-4222-8222-222222222222',
|
|
'connected_at' => '2026-09-12T11:13:55+00:00',
|
|
])->and($this->node->refresh()->is_reachable)->toBeTrue();
|
|
});
|
|
|
|
it('rejects invalid internal credentials and unknown servers', function () {
|
|
$payload = ['event' => 'connected', 'server_id' => $this->node->uuid, 'connection_id' => '11111111-1111-4111-8111-111111111111', 'sentinel_version' => 'main', 'protocol_version' => 1, 'trust_bundle_version' => 1, 'transport' => 'tls'];
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', $payload)->assertUnauthorized();
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [...$payload, 'server_id' => 'missing'], ['Authorization' => 'Bearer internal-secret'])->assertNotFound();
|
|
});
|
|
|
|
it('rejects connection events for legacy servers', function () {
|
|
$server = Server::factory()->create(['team_id' => $this->node->team_id]);
|
|
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'connected',
|
|
'server_id' => $server->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'sentinel_version' => 'main',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'tls',
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNotFound();
|
|
});
|
|
|
|
it('keeps negotiated capabilities after the connection cache expires', function () {
|
|
$this->postJson('/api/v1/internal/sentinel/control/events', [
|
|
'event' => 'connected',
|
|
'server_id' => $this->node->uuid,
|
|
'connection_id' => '11111111-1111-4111-8111-111111111111',
|
|
'sentinel_version' => 'main',
|
|
'protocol_version' => 1,
|
|
'trust_bundle_version' => 1,
|
|
'transport' => 'plaintext',
|
|
'capabilities' => ['container.list.v1'],
|
|
], ['Authorization' => 'Bearer internal-secret'])->assertNoContent();
|
|
|
|
Cache::flush();
|
|
$node = $this->node->refresh();
|
|
|
|
expect($node->sentinel_capabilities)->toBe(['container.list.v1'])
|
|
->and($node->supportsCapability('container.list.v1'))->toBeTrue()
|
|
->and($node->supportsCapability('workload.deploy.v1'))->toBeFalse()
|
|
->and(fn () => $node->ensureCapability('workload.deploy.v1'))->toThrow(RuntimeException::class);
|
|
});
|