mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 00:17:16 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
@@ -35,6 +35,14 @@ it('runs the v5 dev Lima seeder with the normal development database seeder', fu
|
||||
->and($developmentSeederBlock)->toContain('V5DevLimaSeeder::class');
|
||||
});
|
||||
|
||||
it('disables Flux host binding by default in the Docker development environment', function () {
|
||||
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
|
||||
$runScript = file_get_contents(base_path('docker/development/etc/s6-overlay/s6-rc.d/flux/run'));
|
||||
|
||||
expect($compose)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING: "${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"')
|
||||
->and($runScript)->toContain('COOLIFY_FLUX_REQUIRE_HOST_BINDING="${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"');
|
||||
});
|
||||
|
||||
it('cache busts mutable nightly coold assets with resolved checksums', function () {
|
||||
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
|
||||
$dockerfile = file_get_contents(base_path('docker/development/Dockerfile'));
|
||||
|
||||
@@ -17,7 +17,8 @@ it('issues production host tokens with the default capability profile', function
|
||||
|
||||
expect($claims->sub)->toBe('100.64.0.10')
|
||||
->and($claims->aud)->toBe('coold')
|
||||
->and($claims->caps)->toBe(['host-agent:default'])
|
||||
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:default')
|
||||
->and($claims->exp)->toBeGreaterThan(time());
|
||||
});
|
||||
|
||||
@@ -28,6 +29,7 @@ it('issues production server tokens with server identity claims', function () {
|
||||
|
||||
$server = new V5Server;
|
||||
$server->forceFill([
|
||||
'uuid' => 'server_prod_123',
|
||||
'id' => 123,
|
||||
'team_id' => 7,
|
||||
'cluster_id' => 'cluster-456',
|
||||
@@ -38,11 +40,12 @@ it('issues production server tokens with server identity claims', function () {
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('100.64.0.10')
|
||||
->and($claims->caps)->toBe(['host-agent:default'])
|
||||
->and($claims->team_id)->toBe(7)
|
||||
expect($claims->sub)->toBe('server_prod_123')
|
||||
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and($claims->team_id)->toBe('7')
|
||||
->and($claims->cluster_id)->toBe('cluster-456')
|
||||
->and($claims->server_id)->toBe(123);
|
||||
->and($claims->server_id)->toBe('server_prod_123')
|
||||
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('mints a host jwt signed by the configured flux private key', function () {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Models\V5\RevokedAgentToken;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createRevocationJwtKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/revocation-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
function createRevocationServer(): 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',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('v5_revoked_agent_tokens');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createRevokedAgentTokensTable();
|
||||
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createRevocationJwtKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Schema::dropIfExists('v5_revoked_agent_tokens');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
it('persists the issued jti on the server and reports it as not revoked', function () {
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'));
|
||||
|
||||
expect($server->fresh()->agent_token_jti)->toBe($claims->jti)
|
||||
->and(app(AgentTokenIssuer::class)->isRevoked($claims->jti))->toBeFalse();
|
||||
});
|
||||
|
||||
it('revokes the currently-issued token and clears the server jti', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$issuer->revoke($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->where('server_id', $server->id)->exists())->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent when revoking a server with no issued token', function () {
|
||||
$server = createRevocationServer();
|
||||
|
||||
app(AgentTokenIssuer::class)->revoke($server);
|
||||
|
||||
expect(RevokedAgentToken::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('pushes the revocation to flux with the jti and expires_at', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$server = $server->fresh();
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
$expiresAtUnix = $server->agent_token_expires_at->getTimestamp();
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->with($jti, $expiresAtUnix);
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
$issuer->revoke($server);
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not throw and keeps the local revocation record when the flux push fails', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->andThrow(new RuntimeException('flux socket unreachable'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
// Flux is down: revoke must not throw, and the local record still stands.
|
||||
$issuer->revoke($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('revokeForServer revokes the token and pushes to flux like revoke', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->with($jti, Mockery::type('int'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
$issuer->revokeForServer($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the host token when the bootstrap marker is removed', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
// The server has no private key, so the SSH cleanup returns false, but the
|
||||
// revocation (a pure DB write) still runs first.
|
||||
$result = RemoveBootstrapMarker::run($server->fresh());
|
||||
|
||||
expect($result)->toBeFalse()
|
||||
->and($issuer->isRevoked($jti))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\V5RotateAgentTokenJob;
|
||||
use App\Jobs\V5RotateAgentTokensJob;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createRotationJwtKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/rotation-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$this->user, $this->team] = createV5UserWithTeam();
|
||||
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createRotationJwtKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createRotationServer(array $attributes = []): V5Server
|
||||
{
|
||||
static $counter = 0;
|
||||
$counter++;
|
||||
|
||||
return V5Server::query()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'created_by_user_id' => test()->user->id,
|
||||
'name' => 'edge-'.$counter,
|
||||
'host' => '203.0.113.'.$counter,
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['coold'],
|
||||
'wireguard_management_ip' => '100.64.0.'.$counter,
|
||||
'last_bootstrapped_at' => now(),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
it('issueForServer persists agent_token_expires_at approximately now plus the ttl', function () {
|
||||
Config::set('flux.host_token_ttl', 3600);
|
||||
|
||||
$server = createRotationServer();
|
||||
|
||||
app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at)->not->toBeNull()
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThanOrEqual(now()->addSeconds(3600)->subSeconds(30)->timestamp)
|
||||
->toBeLessThanOrEqual(now()->addSeconds(3600)->addSeconds(30)->timestamp);
|
||||
});
|
||||
|
||||
it('dispatches rotation only for eligible servers whose token is missing or near expiry', function () {
|
||||
Config::set('flux.host_token_refresh_threshold', 43200);
|
||||
Queue::fake();
|
||||
|
||||
$neverIssued = createRotationServer(['agent_token_expires_at' => null]);
|
||||
$expiringSoon = createRotationServer(['agent_token_expires_at' => now()->addHours(6)]);
|
||||
$stillFresh = createRotationServer(['agent_token_expires_at' => now()->addHours(20)]);
|
||||
$notInstalled = createRotationServer(['status' => 'added', 'agent_token_expires_at' => null]);
|
||||
$withoutCoold = createRotationServer(['capabilities' => [], 'agent_token_expires_at' => null]);
|
||||
$notBootstrapped = createRotationServer(['last_bootstrapped_at' => null, 'agent_token_expires_at' => null]);
|
||||
|
||||
(new V5RotateAgentTokensJob)->handle();
|
||||
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, 2);
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $neverIssued->id);
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $expiringSoon->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $stillFresh->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notInstalled->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $withoutCoold->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notBootstrapped->id);
|
||||
});
|
||||
|
||||
it('delivers the fresh token over RPC and never touches SSH on RPC success', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake();
|
||||
Log::spy();
|
||||
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => now()->addHour(),
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->with($server->fluxHostId(), Mockery::type('string'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThan(now()->addHours(20)->timestamp);
|
||||
|
||||
Process::assertNothingRan();
|
||||
Log::shouldHaveReceived('debug')->withArgs(
|
||||
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'rpc'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the SSH push when RPC delivery fails and advances jti on SSH success', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
Log::spy();
|
||||
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => now()->addHour(),
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('host is not connected'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThan(now()->addHours(20)->timestamp);
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = $process->command;
|
||||
|
||||
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$script = end($command);
|
||||
|
||||
return str_contains($script, '/etc/coolify/host-jwt')
|
||||
&& str_contains($script, 'chmod 600 /etc/coolify/host-jwt')
|
||||
&& str_contains($script, 'tee /etc/coolify/host-jwt');
|
||||
});
|
||||
Log::shouldHaveReceived('debug')->withArgs(
|
||||
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'ssh'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves jti and expires_at unchanged when both RPC and SSH delivery fail', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
|
||||
|
||||
$expiresAt = now()->addHour();
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => $expiresAt,
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('host is not connected'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->toBe('old-jti')
|
||||
->and($server->agent_token_expires_at->timestamp)->toBe($expiresAt->timestamp);
|
||||
});
|
||||
|
||||
it('pushHostToken dispatches the host.jwt.set command with the jwt for the target host', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'ok',
|
||||
'data' => [],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->pushHostToken('100.64.0.42', 'the-new-host-jwt');
|
||||
}
|
||||
);
|
||||
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
$decoded = json_decode($requestBody, true);
|
||||
|
||||
expect($decoded)->toBeArray()
|
||||
->and($decoded['host_id'])->toBe('100.64.0.42')
|
||||
->and($decoded['command'])->toBe(['type' => 'host.jwt.set', 'jwt' => 'the-new-host-jwt']);
|
||||
});
|
||||
|
||||
it('revokeToken posts the jti and expires_at to the flux revocation endpoint', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->revokeToken('the-jti', 1893456000);
|
||||
}
|
||||
);
|
||||
|
||||
$requestLine = strtok($captured, "\r\n");
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
$decoded = json_decode($requestBody, true);
|
||||
|
||||
expect($requestLine)->toBe('POST /v1/tokens/revoke HTTP/1.1')
|
||||
->and($decoded)->toBe(['jti' => 'the-jti', 'expires_at' => 1893456000]);
|
||||
});
|
||||
|
||||
it('revokeToken omits expires_at from the body when it is null', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->revokeToken('the-jti');
|
||||
}
|
||||
);
|
||||
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
|
||||
expect(json_decode($requestBody, true))->toBe(['jti' => 'the-jti']);
|
||||
});
|
||||
|
||||
it('runs the rotation jobs on the dedicated v5-reconcile queue', function () {
|
||||
expect((new V5RotateAgentTokensJob)->queue)->toBe('v5-reconcile')
|
||||
->and((new V5RotateAgentTokenJob(1))->queue)->toBe('v5-reconcile');
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs $callback against a fake Flux Unix socket that records the full HTTP
|
||||
* request (headers + body) it receives, then returns the captured raw request
|
||||
* so the wire command can be asserted.
|
||||
*/
|
||||
function withCapturingFluxSocket(string $response, Closure $callback): string
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$capturePath = $directory.'/flux-capture-'.bin2hex(random_bytes(8)).'.txt';
|
||||
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($socketServer, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
if (preg_match('/Content-Length:\s*(\d+)/i', $request, $matches) === 1) {
|
||||
$headerEnd = strpos($request, "\r\n\r\n") + 4;
|
||||
$needed = (int) $matches[1];
|
||||
|
||||
while (strlen($request) - $headerEnd < $needed && ! feof($connection)) {
|
||||
$chunk = fread($connection, 8192);
|
||||
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$request .= $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($capturePath, $request);
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
}
|
||||
|
||||
$captured = is_file($capturePath) ? (string) file_get_contents($capturePath) : '';
|
||||
|
||||
@unlink($socketPath);
|
||||
@unlink($capturePath);
|
||||
|
||||
return $captured;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
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_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
Schema::dropIfExists('private_keys');
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
});
|
||||
|
||||
function createBootstrapPreflightTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
|
||||
Schema::create('private_keys', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->longText('private_key');
|
||||
$table->string('fingerprint')->nullable();
|
||||
$table->boolean('is_git_related')->default(false);
|
||||
$table->foreignId('team_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
V5TestSchema::createClustersTable();
|
||||
V5TestSchema::createServersTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Team}
|
||||
*/
|
||||
function createBootstrapPreflightUserWithTeam(): array
|
||||
{
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => 'margaret@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Tooling Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
return [$user, $team];
|
||||
}
|
||||
|
||||
function createBootstrapPreflightPrivateKey(Team $team): PrivateKey
|
||||
{
|
||||
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
|
||||
'uuid' => 'preflight-key-uuid',
|
||||
'name' => 'Preflight SSH Key',
|
||||
'description' => null,
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
'fingerprint' => 'preflight-key',
|
||||
'is_git_related' => false,
|
||||
'team_id' => $team->id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Team, 2: Cluster, 3: V5Server}
|
||||
*/
|
||||
function createBootstrapPreflightClusterWithServer(array $clusterAttributes = [], array $serverAttributes = []): array
|
||||
{
|
||||
[$user, $team] = createBootstrapPreflightUserWithTeam();
|
||||
$privateKey = createBootstrapPreflightPrivateKey($team);
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
...$clusterAttributes,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
...$serverAttributes,
|
||||
]);
|
||||
|
||||
return [$user, $team, $cluster, $server];
|
||||
}
|
||||
|
||||
it('rejects bootstrap when flux url is not configured', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.flux_url', '');
|
||||
|
||||
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
// The claim must not have been written either.
|
||||
expect($server->refresh()->last_bootstrap_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('queues the bootstrap when the flux url is configured', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Bootstrap queued.');
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class);
|
||||
});
|
||||
|
||||
it('writes the marker with the same coold version that is persisted on the server row', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[, , $cluster, $server] = createBootstrapPreflightClusterWithServer([
|
||||
'namespaces' => ['default'],
|
||||
'coold_version' => 'v0.9.9',
|
||||
], [
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$issuer = Mockery::mock(AgentTokenIssuer::class);
|
||||
$issuer->shouldReceive('issueForServer')->andReturn('signed-host-jwt');
|
||||
app()->instance(AgentTokenIssuer::class, $issuer);
|
||||
|
||||
// The CLI reports a newer coold version than the cluster asked for; the
|
||||
// marker and the database row must both use the reported version.
|
||||
Process::fake([
|
||||
'*' => Process::sequence()
|
||||
->push(Process::result(output: ''))
|
||||
->push(Process::result(output: json_encode([
|
||||
'results' => [],
|
||||
'coold_version' => 'v1.2.3',
|
||||
'verified' => [[
|
||||
'host' => "v5-server-{$server->uuid}",
|
||||
'wireguard_ip' => '100.64.0.1',
|
||||
'peer_count' => 0,
|
||||
'status' => 'ok',
|
||||
]],
|
||||
], JSON_THROW_ON_ERROR)."\n"))
|
||||
->push(Process::result(output: "public-key\n"))
|
||||
->push(Process::result(output: "default=10.210.0.0/24\n"))
|
||||
->push(Process::result(output: "marker written\n"))
|
||||
->push(Process::result(output: "coold restarted\n")),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class, V5CanvasResourceUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($server->status)->toBe('installed')
|
||||
->and($server->coold_version)->toBe('v1.2.3');
|
||||
|
||||
$markerPayload = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$markerPayload): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
if (! str_contains($command, 'v5-node.json') || ! str_contains($command, 'base64 -d')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match("/payload='([^']+)'/", $command, $matches) === 1) {
|
||||
$markerPayload = json_decode(base64_decode($matches[1], true), true);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($markerPayload)->toBeArray()
|
||||
->and($markerPayload['coold_version'])->toBe('v1.2.3')
|
||||
->and($markerPayload['coold_version'])->toBe($server->coold_version)
|
||||
->and($markerPayload['server_uuid'])->toBe($server->uuid)
|
||||
->and($markerPayload['wireguard_management_ip'])->toBe('100.64.0.1');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Config::set('broadcasting.default', 'pusher');
|
||||
Config::set('broadcasting.connections.pusher.key', 'test-key');
|
||||
Config::set('broadcasting.connections.pusher.secret', 'test-secret');
|
||||
Config::set('broadcasting.connections.pusher.app_id', 'test-app-id');
|
||||
Config::set('broadcasting.connections.pusher.options', [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6001,
|
||||
'scheme' => 'http',
|
||||
'useTLS' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('authorizes team members on their private team channel', function () {
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonStructure(['auth']);
|
||||
});
|
||||
|
||||
it('rejects users outside the team on the private team channel', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$outsider = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Outsider',
|
||||
'email' => 'outsider@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Another Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$outsider->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($outsider)
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('rejects guests on the private team channel', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrap_action' => 'bootstrap',
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => 'Starting Coolify CLI bootstrap for prod-01...',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->getJson("/v5/clusters/{$cluster->uuid}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('cluster.id', $cluster->uuid)
|
||||
->assertJsonPath('cluster.servers.0.id', $server->uuid)
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...');
|
||||
});
|
||||
|
||||
it('shows v5 clusters with their servers on the cluster page', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'lima-coold-dev',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['builder'],
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 2,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5/clusters')
|
||||
->assertSuccessful()
|
||||
->assertSee('Clusters', false)
|
||||
->assertSee('"clusters":[', false)
|
||||
->assertSee('"privateKeys":[', false)
|
||||
->assertSee('"name":"Lima Key"', false)
|
||||
->assertSee('"name":"Development-Lima"', false)
|
||||
->assertSee('"serversCount":1', false)
|
||||
->assertSee('"name":"coold-dev"', false)
|
||||
->assertSee('"host":"lima-coold-dev"', false)
|
||||
->assertDontSee('"sshUser"', false)
|
||||
->assertDontSee('"sshPort"', false)
|
||||
->assertSee('"builderEnabled":true', false)
|
||||
->assertSee('"builderCapacity":2', false)
|
||||
->assertSee('"wireguardInterface":"wg0"', false)
|
||||
->assertSee('"wireguardManagementPool":"100.64.0.0\\/16"', false)
|
||||
->assertSee('"containerNetworkPool":"10.210.0.0\\/16"', false)
|
||||
->assertSee('"namespaces":["default"]', false)
|
||||
->assertSee('"defaultDenyContainers":true', false)
|
||||
->assertSee('"cooldVersion":"nightly"', false)
|
||||
->assertSee('"corrosionVersion":"v1.0.0"', false)
|
||||
->assertSee('"builderCpuQuota":"200%"', false)
|
||||
->assertSee('"builderMemoryMax":"2G"', false)
|
||||
->assertSee('"builderTimeoutSecs":1800', false)
|
||||
->assertSee('"lastCliStatus":null', false)
|
||||
->assertSee('"privateKeyName":"Lima Key"', false)
|
||||
->assertSee('"nodeAddress":null', false)
|
||||
->assertSee('"wireguardManagementIp":null', false)
|
||||
->assertSee('"containerSubnets":[]', false)
|
||||
->assertSee('"lastBootstrappedAt":"', false);
|
||||
});
|
||||
|
||||
it('creates a v5 cluster for the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Primary production cluster.',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.name', 'Production Mesh')
|
||||
->assertJsonPath('cluster.description', 'Primary production cluster.')
|
||||
->assertJsonPath('cluster.wireguardInterface', 'wg0')
|
||||
->assertJsonPath('cluster.wireguardManagementPool', '100.64.0.0/16')
|
||||
->assertJsonPath('cluster.wireguardListenPort', 51820)
|
||||
->assertJsonPath('cluster.containerNetworkPool', '10.210.0.0/16')
|
||||
->assertJsonPath('cluster.containerNetworkPrefix', 24)
|
||||
->assertJsonPath('cluster.namespaces', ['default'])
|
||||
->assertJsonPath('cluster.defaultDenyContainers', true)
|
||||
->assertJsonPath('cluster.cooldVersion', 'nightly')
|
||||
->assertJsonPath('cluster.corrosionVersion', 'v1.0.0')
|
||||
->assertJsonPath('cluster.corrosionGossipPort', 8787)
|
||||
->assertJsonPath('cluster.corrosionApiPort', 8080)
|
||||
->assertJsonPath('cluster.builderEnabled', true)
|
||||
->assertJsonPath('cluster.builderCapacity', 2)
|
||||
->assertJsonPath('cluster.builderCpuQuota', '200%')
|
||||
->assertJsonPath('cluster.builderMemoryMax', '2G')
|
||||
->assertJsonPath('cluster.builderTimeoutSecs', 1800)
|
||||
->assertJsonPath('cluster.lastCliAction', null)
|
||||
->assertJsonPath('cluster.lastCliStatus', null)
|
||||
->assertJsonPath('cluster.lastCliSummary', null)
|
||||
->assertJsonPath('cluster.lastCliRanAt', null)
|
||||
->assertJsonPath('cluster.serversCount', 0)
|
||||
->assertJsonPath('cluster.servers', []);
|
||||
|
||||
expect(Cluster::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->where('name', 'Production Mesh')
|
||||
->where('description', 'Primary production cluster.')
|
||||
->where('wireguard_interface', 'wg0')
|
||||
->where('wireguard_management_pool', '100.64.0.0/16')
|
||||
->where('container_network_pool', '10.210.0.0/16')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates a v5 cluster with advanced cli configuration', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Custom Mesh',
|
||||
'description' => null,
|
||||
'wireguard_interface' => 'wg-prod',
|
||||
'wireguard_management_pool' => '100.65.0.0/16',
|
||||
'wireguard_listen_port' => 51830,
|
||||
'container_network_pool' => '10.211.0.0/16',
|
||||
'container_network_prefix' => 25,
|
||||
'namespaces' => ['default', 'preview'],
|
||||
'default_deny_containers' => false,
|
||||
'coold_version' => 'v0.2.0',
|
||||
'corrosion_version' => 'v1.1.0',
|
||||
'corrosion_gossip_port' => 8788,
|
||||
'corrosion_api_port' => 8081,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 4,
|
||||
'builder_cpu_quota' => '400%',
|
||||
'builder_memory_max' => '4G',
|
||||
'builder_timeout_secs' => 2400,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.wireguardInterface', 'wg-prod')
|
||||
->assertJsonPath('cluster.wireguardManagementPool', '100.65.0.0/16')
|
||||
->assertJsonPath('cluster.wireguardListenPort', 51830)
|
||||
->assertJsonPath('cluster.containerNetworkPool', '10.211.0.0/16')
|
||||
->assertJsonPath('cluster.containerNetworkPrefix', 25)
|
||||
->assertJsonPath('cluster.namespaces', ['default', 'preview'])
|
||||
->assertJsonPath('cluster.defaultDenyContainers', false)
|
||||
->assertJsonPath('cluster.cooldVersion', 'v0.2.0')
|
||||
->assertJsonPath('cluster.corrosionVersion', 'v1.1.0')
|
||||
->assertJsonPath('cluster.builderCapacity', 4)
|
||||
->assertJsonPath('cluster.builderCpuQuota', '400%')
|
||||
->assertJsonPath('cluster.builderMemoryMax', '4G')
|
||||
->assertJsonPath('cluster.builderTimeoutSecs', 2400);
|
||||
});
|
||||
|
||||
it('validates advanced v5 cluster cli configuration', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Broken Mesh',
|
||||
'wireguard_management_pool' => 'not-a-cidr',
|
||||
'container_network_pool' => '10.211.0.0',
|
||||
'wireguard_listen_port' => 70000,
|
||||
'namespaces' => ['Default'],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'wireguard_management_pool',
|
||||
'container_network_pool',
|
||||
'wireguard_listen_port',
|
||||
'namespaces.0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires positive v5 cluster builder capacity when builders are enabled', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Zero Builder Mesh',
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 0,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['builder_capacity']);
|
||||
});
|
||||
|
||||
it('validates v5 cluster creation input', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => '',
|
||||
'description' => str_repeat('a', 1001),
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name', 'description']);
|
||||
});
|
||||
|
||||
it('rejects duplicate v5 cluster names in the same team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
it('deletes an empty v5 cluster in the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Empty Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertNoContent();
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not delete a v5 cluster that has servers', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => null,
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'lima-coold-dev',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['builder'],
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 2,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Only empty clusters can be deleted.');
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not delete a v5 cluster outside the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$otherUser = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Other User',
|
||||
'email' => 'other-delete@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'created_by_user_id' => $otherUser->id,
|
||||
'name' => 'Other Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertNotFound();
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows the same v5 cluster name in another team without leaking it', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$otherUser = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Other User',
|
||||
'email' => 'other@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
Cluster::query()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'created_by_user_id' => $otherUser->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Other team cluster.',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Current team cluster.',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.description', 'Current team cluster.');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5/clusters')
|
||||
->assertSuccessful()
|
||||
->assertSee('Current team cluster.')
|
||||
->assertDontSee('Other team cluster.');
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('serves the v5 inertia shell', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
$this->withoutExceptionHandling();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Shared Team',
|
||||
'description' => 'Shared team details',
|
||||
'personal_team' => true,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('<html lang="en" class="dark">', false)
|
||||
->assertSee('coolify-logo-dev-transparent.png', false)
|
||||
->assertDontSee('coolify-logo.svg', false)
|
||||
->assertSee('js/echo.js', false)
|
||||
->assertSee('js/pusher.js', false)
|
||||
->assertSee('window.Echo', false)
|
||||
->assertSee('v5-app', false)
|
||||
->assertSee('Dashboard', false)
|
||||
->assertDontSee('Home', false)
|
||||
->assertDontSee('v5-ready', false)
|
||||
->assertDontSee('This page is served from Laravel through Inertia and React')
|
||||
->assertDontSee('Bootstrap server')
|
||||
->assertDontSee('privateKeys', false)
|
||||
->assertSee('Running')
|
||||
->assertSee('Flux is running.')
|
||||
->assertDontSee('"clusters":', false)
|
||||
->assertDontSee('cooldServers', false)
|
||||
->assertDontSee('coold-dev')
|
||||
->assertDontSee('Create cluster')
|
||||
->assertDontSee('Cluster details')
|
||||
->assertDontSee('100.64.0.1')
|
||||
->assertDontSee('Current team')
|
||||
->assertDontSee('Your teams')
|
||||
->assertSee('currentTeam', false)
|
||||
->assertSee('"currentTeam":{"id":'.$team->id, false)
|
||||
->assertDontSee('teams', false)
|
||||
->assertDontSee('V5 Shared Team')
|
||||
->assertDontSee('Shared team details');
|
||||
});
|
||||
|
||||
it('serves v5 dashboard applications as canvas nodes', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($team, 'Staging Project', 'Staging');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'abc123',
|
||||
'mesh_namespace' => 'default',
|
||||
'canvas_x' => 120,
|
||||
'canvas_y' => -80,
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $otherProject->id,
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'other-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-other',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"applications":[', false)
|
||||
->assertSee('"name":"nginx-test"', false)
|
||||
->assertSee('"serverName":"edge-01"', false)
|
||||
->assertSee('"meshNamespace":"default"', false)
|
||||
->assertSee('"meshFqdn":"coolify-v5-nginx-1.default.coolify.internal"', false)
|
||||
->assertSee('"canvasX":120', false)
|
||||
->assertSee('"canvasY":-80', false)
|
||||
->assertDontSee('other-nginx-test', false);
|
||||
});
|
||||
|
||||
it('marks v5 application status as unknown when its server is unreachable', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'unreachable',
|
||||
'last_status_output' => 'coold heartbeat timed out.',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"status":"running"', false)
|
||||
->assertSee('"effectiveStatus":"unknown"', false)
|
||||
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
|
||||
->assertSee('"serverStatus":"unreachable"', false)
|
||||
->assertSee('"isServerReachable":false', false);
|
||||
});
|
||||
|
||||
it('shows v5 caddy ingress as unreachable when its server is unreachable', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'unreachable',
|
||||
'ingress_status' => 'running',
|
||||
'last_status_output' => 'coold heartbeat timed out.',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"caddyIngresses":[', false)
|
||||
->assertSee('"status":"unreachable"', false)
|
||||
->assertSee('"statusMessage":"coold heartbeat timed out."', false);
|
||||
});
|
||||
|
||||
it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'ingress_status' => 'running',
|
||||
'capabilities' => ['ingress'],
|
||||
'canvas_x' => -160,
|
||||
'canvas_y' => 240,
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-02',
|
||||
'host' => '203.0.113.22',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'ingress_status' => 'exited',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-worker-01',
|
||||
'host' => '203.0.113.21',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"caddyIngresses":[', false)
|
||||
->assertSee('"name":"edge-ingress-01"', false)
|
||||
->assertSee('"host":"203.0.113.20"', false)
|
||||
->assertSee('"type":"caddy"', false)
|
||||
->assertSee('"status":"running"', false)
|
||||
->assertSee('"name":"edge-ingress-02"', false)
|
||||
->assertSee('"status":"exited"', false)
|
||||
->assertSee('"canvasX":-160', false)
|
||||
->assertSee('"canvasY":240', false);
|
||||
});
|
||||
|
||||
it('broadcasts manual v5 realtime test events immediately', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
Event::fake([V5RealtimeTestEvent::class]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/realtime-test', [
|
||||
'message' => 'hello socket',
|
||||
])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Realtime test event broadcasted.');
|
||||
|
||||
Event::assertDispatched(V5RealtimeTestEvent::class, fn (V5RealtimeTestEvent $event): bool => $event->teamId === $team->id
|
||||
&& $event->message === 'hello socket'
|
||||
&& $event->broadcastAs() === 'v5.realtime.test'
|
||||
&& $event instanceof ShouldBroadcastNow);
|
||||
});
|
||||
|
||||
it('serves the production v5 favicon outside local environments', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('coolify-logo.svg', false)
|
||||
->assertDontSee('coolify-logo-dev-transparent.png', false);
|
||||
});
|
||||
|
||||
it('shares existing projects and environments with the v5 dashboard page', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production');
|
||||
createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging');
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"projects":[', false)
|
||||
->assertSee('"name":"gravy-truck"', false)
|
||||
->assertSee('"uuid":"'.$project->uuid.'"', false)
|
||||
->assertSee('"environments":[', false)
|
||||
->assertSee('"name":"production"', false)
|
||||
->assertSee('"uuid":"'.$environment->uuid.'"', false)
|
||||
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
|
||||
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
|
||||
->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false)
|
||||
->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false)
|
||||
->assertDontSee($otherProject->name)
|
||||
->assertDontSee($otherProject->uuid)
|
||||
->assertDontSee($otherEnvironment->name)
|
||||
->assertDontSee($otherEnvironment->uuid);
|
||||
});
|
||||
|
||||
it('persists the selected v5 project and environment in the session', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5ProjectWithEnvironment($team, 'alpha-project', 'production');
|
||||
[$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/selection', [
|
||||
'project_uuid' => $selectedProject->uuid,
|
||||
'environment_uuid' => $selectedEnvironment->uuid,
|
||||
])
|
||||
->assertNoContent()
|
||||
->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid)
|
||||
->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false)
|
||||
->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false);
|
||||
});
|
||||
|
||||
it('rejects persisted v5 selections outside the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/selection', [
|
||||
'project_uuid' => $otherProject->uuid,
|
||||
'environment_uuid' => $otherEnvironment->uuid,
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertSessionMissing('v5.selectedProjectUuid')
|
||||
->assertSessionMissing('v5.selectedEnvironmentUuid');
|
||||
});
|
||||
|
||||
it('serves v5 dev assets from the current request host', function (string $url, string $viteHost) {
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$hotFile = public_path('hot');
|
||||
$originalHotFile = file_exists($hotFile) ? file_get_contents($hotFile) : null;
|
||||
|
||||
file_put_contents($hotFile, 'http://configured-vite-host.test:5173');
|
||||
|
||||
try {
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get($url);
|
||||
|
||||
$response
|
||||
->assertSuccessful()
|
||||
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
|
||||
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
|
||||
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
|
||||
->assertDontSee('configured-vite-host.test', false);
|
||||
} finally {
|
||||
if ($originalHotFile === null) {
|
||||
@unlink($hotFile);
|
||||
} else {
|
||||
file_put_contents($hotFile, $originalHotFile);
|
||||
}
|
||||
}
|
||||
})->with([
|
||||
'localhost' => ['http://localhost:8000/v5', 'localhost'],
|
||||
'tailscale ip' => ['http://100.64.0.10:8000/v5', '100.64.0.10'],
|
||||
]);
|
||||
|
||||
it('configures the vite dev server for remote v5 access', function () {
|
||||
$viteConfig = file_get_contents(base_path('vite.config.js'));
|
||||
|
||||
expect($viteConfig)
|
||||
->toContain('host: "0.0.0.0"')
|
||||
->toContain('allowedHosts: true')
|
||||
->toContain('cors: true')
|
||||
->toContain('const viteHmrHost = env.VITE_HMR_HOST || null;')
|
||||
->toContain('hmr: viteHmrHost')
|
||||
->not->toContain('hmr: viteHost');
|
||||
});
|
||||
|
||||
it('shows when flux is unavailable', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth(false, 'Flux socket was not found.');
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Katherine Johnson',
|
||||
'email' => 'katherine@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Flux Test Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('Unavailable')
|
||||
->assertSee('Flux socket was not found.');
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\V5\EnsureCurrentTeam;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
});
|
||||
|
||||
function createEnsureCurrentTeamUser(string $email = 'margaret@example.com'): User
|
||||
{
|
||||
return User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
}
|
||||
|
||||
function createEnsureCurrentTeamTeam(string $name = 'V5 Tooling Team'): Team
|
||||
{
|
||||
return Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => $name,
|
||||
'description' => 'The team that ships v5.',
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the middleware against a request the way the v5.authenticated group
|
||||
* would: an authenticated user with an active session store.
|
||||
*
|
||||
* @return array{0: Response, 1: Request}
|
||||
*/
|
||||
function runEnsureCurrentTeam(?User $user): array
|
||||
{
|
||||
$request = Request::create('/v5', 'GET');
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new EnsureCurrentTeam)->handle($request, fn () => response()->noContent());
|
||||
|
||||
return [$response, $request];
|
||||
}
|
||||
|
||||
it('aborts 403 when the user belongs to no team', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
|
||||
expect(fn () => runEnsureCurrentTeam($user))
|
||||
->toThrow(fn (HttpException $exception) => expect($exception->getStatusCode())->toBe(403));
|
||||
});
|
||||
|
||||
it('falls back to first team when the session team is no longer a member', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$memberTeam = createEnsureCurrentTeamTeam('Member Team');
|
||||
$foreignTeam = createEnsureCurrentTeamTeam('Foreign Team');
|
||||
$user->teams()->attach($memberTeam, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $foreignTeam]);
|
||||
|
||||
[, $request] = runEnsureCurrentTeam($user);
|
||||
|
||||
expect($request->attributes->get('v5.currentTeam')->id)->toBe($memberTeam->id)
|
||||
->and(session('currentTeam'))->toBeInstanceOf(Team::class)
|
||||
->and(session('currentTeam')->id)->toBe($memberTeam->id)
|
||||
->and(session('currentTeam')->name)->toBe('Member Team');
|
||||
});
|
||||
|
||||
it('stores the full team model so v4 session reads keep working', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$team = createEnsureCurrentTeamTeam();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
runEnsureCurrentTeam($user);
|
||||
|
||||
$sessionTeam = session('currentTeam');
|
||||
|
||||
// v4 reads columns beyond id/name/description/personal_team off the
|
||||
// session team (e.g. show_boarding, custom limits), so the stored model
|
||||
// must not be a partial select.
|
||||
expect($sessionTeam)->toBeInstanceOf(Team::class)
|
||||
->and($sessionTeam->getAttributes())->toHaveKeys([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'personal_team',
|
||||
'show_boarding',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->and($sessionTeam->created_at)->not->toBeNull()
|
||||
->and((bool) $sessionTeam->show_boarding)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not rewrite the session when the team is unchanged', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$team = createEnsureCurrentTeamTeam();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
// Prime the session with the same team but a sentinel attribute; if the
|
||||
// middleware skips the redundant write, the sentinel survives the request.
|
||||
$sessionTeam = Team::query()->findOrFail($team->id);
|
||||
$sessionTeam->name = 'Session Sentinel';
|
||||
session(['currentTeam' => $sessionTeam]);
|
||||
|
||||
[, $request] = runEnsureCurrentTeam($user);
|
||||
|
||||
expect($request->attributes->get('v5.currentTeam'))->toBeInstanceOf(Team::class)
|
||||
->and($request->attributes->get('v5.currentTeam')->id)->toBe($team->id)
|
||||
->and($request->attributes->get('v5.currentTeam')->name)->toBe('V5 Tooling Team')
|
||||
->and(session('currentTeam'))->toBe($sessionTeam)
|
||||
->and(session('currentTeam')->name)->toBe('Session Sentinel');
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->keyDir = storage_path('framework/testing/flux-keygen-'.bin2hex(random_bytes(4)));
|
||||
$this->privateKeyPath = $this->keyDir.'/jwt.priv';
|
||||
$this->publicKeyPath = $this->keyDir.'/jwt.pub';
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
Config::set('flux.jwt_public_key_path', $this->publicKeyPath);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->keyDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return string octal file/dir mode, e.g. "0600"
|
||||
*/
|
||||
function fluxKeyMode(string $path): string
|
||||
{
|
||||
clearstatcache();
|
||||
|
||||
return sprintf('%04o', fileperms($path) & 0777);
|
||||
}
|
||||
|
||||
it('generates a working ES256 keypair at the configured paths with strict perms', function () {
|
||||
$this->artisan('v5:flux-generate-keys')->assertSuccessful();
|
||||
|
||||
expect(File::exists($this->privateKeyPath))->toBeTrue()
|
||||
->and(File::exists($this->publicKeyPath))->toBeTrue()
|
||||
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600')
|
||||
->and(fluxKeyMode($this->publicKeyPath))->toBe('0644')
|
||||
->and(fluxKeyMode($this->keyDir))->toBe('0700');
|
||||
|
||||
// The generated private key must be usable by the real issuer...
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
|
||||
expect(substr_count($token, '.'))->toBe(2);
|
||||
|
||||
// ...and verifiable with the generated public key (proves the pair matches).
|
||||
$claims = JWT::decode($token, new Key(File::get($this->publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing private key without --force', function () {
|
||||
File::ensureDirectoryExists($this->keyDir);
|
||||
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
|
||||
|
||||
$this->artisan('v5:flux-generate-keys')
|
||||
->expectsOutputToContain('already exists')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(File::get($this->privateKeyPath))->toBe('SENTINEL-PRIVATE-KEY');
|
||||
});
|
||||
|
||||
it('overwrites an existing private key with --force and produces a valid keypair', function () {
|
||||
File::ensureDirectoryExists($this->keyDir);
|
||||
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
|
||||
|
||||
$this->artisan('v5:flux-generate-keys --force')->assertSuccessful();
|
||||
|
||||
expect(File::get($this->privateKeyPath))->not->toBe('SENTINEL-PRIVATE-KEY')
|
||||
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600');
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
|
||||
expect(substr_count($token, '.'))->toBe(2);
|
||||
});
|
||||
|
||||
it('prints the public key when --show-public is passed', function () {
|
||||
$this->artisan('v5:flux-generate-keys --show-public')
|
||||
->expectsOutputToContain('BEGIN PUBLIC KEY')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
V5TestSchema::dropAllTables();
|
||||
V5TestSchema::createAllTables();
|
||||
|
||||
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',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
V5TestSchema::dropAllTables();
|
||||
});
|
||||
|
||||
function postFluxContainerStatus(string $bearer): TestResponse
|
||||
{
|
||||
return test()->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'running',
|
||||
], [
|
||||
'Authorization' => 'Bearer '.$bearer,
|
||||
]);
|
||||
}
|
||||
|
||||
it('accepts an inbound token listed in the rotatable token array', function () {
|
||||
Config::set('flux.laravel_api_token', null);
|
||||
Config::set('flux.laravel_api_tokens', ['old-token', 'new-token']);
|
||||
|
||||
postFluxContainerStatus('new-token')->assertSuccessful();
|
||||
postFluxContainerStatus('old-token')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('still accepts the single fallback token alongside the array', function () {
|
||||
Config::set('flux.laravel_api_token', 'single-token');
|
||||
Config::set('flux.laravel_api_tokens', ['array-token']);
|
||||
|
||||
postFluxContainerStatus('single-token')->assertSuccessful();
|
||||
postFluxContainerStatus('array-token')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('rejects a token that is in neither the array nor the fallback', function () {
|
||||
Config::set('flux.laravel_api_token', 'single-token');
|
||||
Config::set('flux.laravel_api_tokens', ['array-token']);
|
||||
|
||||
postFluxContainerStatus('unknown-token')->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('rejects an empty bearer token even when no tokens are configured', function () {
|
||||
Config::set('flux.laravel_api_token', null);
|
||||
Config::set('flux.laravel_api_tokens', []);
|
||||
|
||||
postFluxContainerStatus('')->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
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');
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createContainerStatusesTable();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createFluxIngestionServer(array $overrides = []): 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' => ServerStatus::Installed->value,
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createFluxIngestionApplication(V5Server $server, array $overrides = []): 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-1',
|
||||
'status' => ApplicationStatus::Running->value,
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
it('maps unknown flux application status values to unknown and logs a warning', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'Exploded',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Unknown->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'unknown flux resource status')
|
||||
&& ($context['raw_status'] ?? null) === 'Exploded'
|
||||
&& ($context['status_enum'] ?? null) === ApplicationStatus::class)
|
||||
->once();
|
||||
});
|
||||
|
||||
it('maps unknown flux container states to unknown when upserting container statuses', function () {
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'levitating',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(ContainerStatus::class)
|
||||
->and($resource->status)->toBe(ContainerState::Unknown->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => ($context['raw_status'] ?? null) === 'levitating'
|
||||
&& ($context['status_enum'] ?? null) === ContainerState::class)
|
||||
->once();
|
||||
});
|
||||
|
||||
it('still applies known statuses case insensitively without warnings', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'EXITED',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value);
|
||||
|
||||
Log::shouldNotHaveReceived('warning');
|
||||
});
|
||||
|
||||
it('drops flux application status updates that are older than the persisted observation', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T11:00:00Z',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Application::class)
|
||||
->and($application->refresh()->status)->toBe(ApplicationStatus::Running->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux application status'))
|
||||
->once();
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('applies flux application status updates with a newer observation and persists the timestamp', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T10:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Container exited.',
|
||||
'observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_message)->toBe('Container exited.')
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('keeps applying flux application status updates that carry no observation timestamp', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
});
|
||||
|
||||
it('drops stale flux server status updates but applies newer observations', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer([
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'observed_at' => '2026-07-05T11:59:59Z',
|
||||
]);
|
||||
|
||||
expect($server->refresh()->status)->toBe(ServerStatus::Installed->value);
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux server status'))
|
||||
->once();
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'observed_at' => '2026-07-05T12:00:01Z',
|
||||
]);
|
||||
|
||||
expect($server->refresh()->status)->toBe(ServerStatus::Unreachable->value)
|
||||
->and($server->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:01+00:00');
|
||||
});
|
||||
|
||||
it('drops stale flux container status updates that are older than the persisted observation', function () {
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
ContainerStatus::query()->create([
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => ContainerState::Running->value,
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T11:00:00Z',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(ContainerStatus::class)
|
||||
->and($resource->status)->toBe(ContainerState::Running->value);
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux container status'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('logs a warning when a flux host id matches multiple v5 servers', function () {
|
||||
Log::spy();
|
||||
|
||||
$serverOne = createFluxIngestionServer();
|
||||
$serverTwo = createFluxIngestionServer([
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeNull()
|
||||
->and($serverOne->refresh()->status)->toBe(ServerStatus::Installed->value)
|
||||
->and($serverTwo->refresh()->status)->toBe(ServerStatus::Installed->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'matches multiple v5 servers')
|
||||
&& ($context['host_id'] ?? null) === '100.64.0.5'
|
||||
&& ($context['server_ids'] ?? null) === [$serverOne->id, $serverTwo->id])
|
||||
->once();
|
||||
});
|
||||
|
||||
it('accepts flux resource status http payloads with a valid observed_at timestamp', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T12:00:00Z',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('message', 'Resource status updated.');
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
});
|
||||
|
||||
it('rejects flux resource status http payloads with an invalid observed_at value', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'exited',
|
||||
'observed_at' => 'not-a-timestamp',
|
||||
])
|
||||
->assertInvalid(['observed_at']);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @param string|array<int, string> $queue
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function horizonSupervisorQueues(string|array $queue): array
|
||||
{
|
||||
$queues = is_array($queue) ? $queue : explode(',', $queue);
|
||||
|
||||
return array_map('trim', $queues);
|
||||
}
|
||||
|
||||
it('defines a horizon supervisor that consumes the v5-reconcile queue', function () {
|
||||
$supervisors = config('horizon.defaults');
|
||||
|
||||
$handlesReconcile = collect($supervisors)->contains(
|
||||
fn (array $config): bool => in_array('v5-reconcile', horizonSupervisorQueues($config['queue'] ?? ''), true)
|
||||
);
|
||||
|
||||
expect($handlesReconcile)->toBeTrue();
|
||||
});
|
||||
|
||||
it('provisions the v5-reconcile supervisor in both production and local environments', function () {
|
||||
expect(config('horizon.environments.production.v5reconcile'))->toBeArray()
|
||||
->and(config('horizon.environments.local.v5reconcile'))->toBeArray()
|
||||
->and(config('horizon.defaults.v5reconcile.queue'))->toBe('v5-reconcile');
|
||||
});
|
||||
|
||||
it('keeps the v5-reconcile supervisor isolated from the user-facing deploy queues', function () {
|
||||
$reconcileQueues = horizonSupervisorQueues(config('horizon.defaults.v5reconcile.queue'));
|
||||
|
||||
expect($reconcileQueues)->not->toContain('high')
|
||||
->and($reconcileQueues)->not->toContain('default')
|
||||
->and(config('horizon.defaults.v5reconcile.nice'))->toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\V5ReconcileServersJob;
|
||||
use App\Jobs\V5ReconcileServerStateJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\ResourceConnectionRule;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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');
|
||||
|
||||
dropModelHygieneTables();
|
||||
createModelHygieneTables();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
dropModelHygieneTables();
|
||||
});
|
||||
|
||||
it('registers the v5 application morph alias without enforcing a global morph map', function () {
|
||||
expect((new V5Application)->getMorphClass())->toBe('v5.application')
|
||||
->and(Relation::getMorphedModel('v5.application'))->toBe(V5Application::class)
|
||||
// v4 models keep storing FQCNs — the map must not be enforced.
|
||||
->and((new Application)->getMorphClass())->toBe(Application::class);
|
||||
});
|
||||
|
||||
it('stores v5 resource connections with the morph alias and resolves it back', function () {
|
||||
$source = createModelHygieneApplication('api');
|
||||
$target = createModelHygieneApplication('postgres');
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => collect([
|
||||
$source->getMorphClass().':'.$source->getKey(),
|
||||
$target->getMorphClass().':'.$target->getKey(),
|
||||
])->sort()->implode('|'),
|
||||
'created_by_user_id' => 1,
|
||||
]);
|
||||
|
||||
$storedRow = DB::table('v5_resource_connections')->where('id', $connection->id)->first();
|
||||
|
||||
expect($storedRow->resource_one_type)->toBe('v5.application')
|
||||
->and($storedRow->resource_two_type)->toBe('v5.application')
|
||||
->and($storedRow->resource_pair_key)->toBe("v5.application:{$source->id}|v5.application:{$target->id}")
|
||||
->and($connection->refresh()->resourceOne)->toBeInstanceOf(V5Application::class)
|
||||
->and($connection->resourceOne->id)->toBe($source->id)
|
||||
->and($connection->resourceTwo->id)->toBe($target->id);
|
||||
});
|
||||
|
||||
it('rewrites legacy fqcn morph rows to the v5 application alias', function () {
|
||||
$fqcn = 'App\Models\V5\Application';
|
||||
|
||||
DB::table('v5_resource_connections')->insert([
|
||||
'uuid' => 'legacy-connection',
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $fqcn,
|
||||
'resource_one_id' => 1,
|
||||
'resource_two_type' => $fqcn,
|
||||
'resource_two_id' => 2,
|
||||
'resource_pair_key' => "{$fqcn}:1|{$fqcn}:2",
|
||||
'created_by_user_id' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('v5_resource_connection_rules')->insert([
|
||||
'connection_id' => 1,
|
||||
'source_resource_type' => $fqcn,
|
||||
'source_resource_id' => 1,
|
||||
'target_resource_type' => $fqcn,
|
||||
'target_resource_id' => 2,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$migration = include database_path('migrations/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php');
|
||||
$migration->up();
|
||||
|
||||
$connection = DB::table('v5_resource_connections')->where('uuid', 'legacy-connection')->first();
|
||||
$rule = DB::table('v5_resource_connection_rules')->first();
|
||||
|
||||
expect($connection->resource_one_type)->toBe('v5.application')
|
||||
->and($connection->resource_two_type)->toBe('v5.application')
|
||||
->and($connection->resource_pair_key)->toBe('v5.application:1|v5.application:2')
|
||||
->and($rule->source_resource_type)->toBe('v5.application')
|
||||
->and($rule->target_resource_type)->toBe('v5.application');
|
||||
});
|
||||
|
||||
it('maps the capabilities array onto the indexed booleans', function () {
|
||||
$server = new V5Server(['capabilities' => ['ingress']]);
|
||||
|
||||
expect($server->is_ingress)->toBeTrue()
|
||||
->and($server->has_coold)->toBeFalse()
|
||||
->and($server->isIngress())->toBeTrue()
|
||||
->and($server->hasCapability('ingress'))->toBeTrue()
|
||||
->and($server->hasCapability('coold'))->toBeFalse()
|
||||
->and($server->hasCapability('builder'))->toBeFalse()
|
||||
->and($server->capabilities)->toBe(['ingress']);
|
||||
|
||||
$server->has_coold = true;
|
||||
|
||||
expect($server->capabilities)->toBe(['coold', 'ingress']);
|
||||
|
||||
$server->capabilities = [];
|
||||
|
||||
expect($server->has_coold)->toBeFalse()
|
||||
->and($server->is_ingress)->toBeFalse()
|
||||
->and($server->capabilities)->toBe([]);
|
||||
});
|
||||
|
||||
it('persists capabilities writes into the boolean columns', function () {
|
||||
$server = createModelHygieneServer(['capabilities' => ['coold']]);
|
||||
|
||||
$storedRow = DB::table('v5_servers')->where('id', $server->id)->first();
|
||||
|
||||
expect((bool) $storedRow->has_coold)->toBeTrue()
|
||||
->and((bool) $storedRow->is_ingress)->toBeFalse()
|
||||
->and($server->fresh()->capabilities)->toBe(['coold']);
|
||||
});
|
||||
|
||||
it('backfills the capability booleans from the legacy json column', function () {
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::create('v5_servers', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->nullable()->unique();
|
||||
$table->string('name');
|
||||
$table->json('capabilities')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
DB::table('v5_servers')->insert([
|
||||
['name' => 'full', 'capabilities' => json_encode(['coold', 'ingress'])],
|
||||
['name' => 'ingress-only', 'capabilities' => json_encode(['ingress'])],
|
||||
['name' => 'unknown-only', 'capabilities' => json_encode(['builder'])],
|
||||
['name' => 'empty', 'capabilities' => null],
|
||||
]);
|
||||
|
||||
$migration = include database_path('migrations/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php');
|
||||
$migration->up();
|
||||
|
||||
$serversByName = DB::table('v5_servers')->get()->keyBy('name');
|
||||
|
||||
expect(Schema::hasColumn('v5_servers', 'capabilities'))->toBeFalse()
|
||||
->and((bool) $serversByName['full']->has_coold)->toBeTrue()
|
||||
->and((bool) $serversByName['full']->is_ingress)->toBeTrue()
|
||||
->and((bool) $serversByName['ingress-only']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['ingress-only']->is_ingress)->toBeTrue()
|
||||
->and((bool) $serversByName['unknown-only']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['unknown-only']->is_ingress)->toBeFalse()
|
||||
->and((bool) $serversByName['empty']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['empty']->is_ingress)->toBeFalse();
|
||||
});
|
||||
|
||||
it('selects reconcile-eligible servers through the indexed has_coold column', function () {
|
||||
Queue::fake();
|
||||
|
||||
$eligible = createModelHygieneServer(['name' => 'coold-node', 'host' => '203.0.113.10', 'has_coold' => true]);
|
||||
createModelHygieneServer(['name' => 'plain-node', 'host' => '203.0.113.11', 'has_coold' => false]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
(new V5ReconcileServersJob)->handle();
|
||||
$queries = collect(DB::getQueryLog())->pluck('query')->implode(' ');
|
||||
DB::disableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($queries)->toContain('has_coold');
|
||||
Queue::assertPushed(V5ReconcileServerStateJob::class, 1);
|
||||
Queue::assertPushed(
|
||||
V5ReconcileServerStateJob::class,
|
||||
fn (V5ReconcileServerStateJob $job) => $job->serverId === $eligible->id,
|
||||
);
|
||||
});
|
||||
|
||||
it('generates uuids only for models whose tables have a uuid column', function () {
|
||||
$server = createModelHygieneServer();
|
||||
|
||||
expect($server->uuid)->not->toBeNull()
|
||||
->and($server->getRouteKeyName())->toBe('uuid');
|
||||
|
||||
// v5_container_statuses has no uuid column: the insert would fail if the
|
||||
// base model still tried to assign one.
|
||||
$containerStatus = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'abc123',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
expect($containerStatus->getAttribute('uuid'))->toBeNull()
|
||||
->and($containerStatus->getRouteKeyName())->toBe('id')
|
||||
->and((new ApplicationDomain)->getRouteKeyName())->toBe('id')
|
||||
->and((new ResourceConnectionRule)->getRouteKeyName())->toBe('id');
|
||||
});
|
||||
|
||||
it('keeps explicitly provided uuids', function () {
|
||||
$server = createModelHygieneServer(['uuid' => 'chosen-uuid']);
|
||||
|
||||
expect($server->uuid)->toBe('chosen-uuid');
|
||||
});
|
||||
|
||||
it('regenerates a colliding public id before inserting', function () {
|
||||
createModelHygieneServer(['uuid' => 'taken-uuid']);
|
||||
|
||||
$colliding = new class extends V5Server
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
public array $candidates = ['taken-uuid', 'fresh-uuid'];
|
||||
|
||||
protected function newPublicIdCandidate(): string
|
||||
{
|
||||
return array_shift($this->candidates);
|
||||
}
|
||||
};
|
||||
|
||||
$colliding->forceFill([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'collision-node',
|
||||
'host' => '203.0.113.99',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
])->save();
|
||||
|
||||
expect($colliding->uuid)->toBe('fresh-uuid');
|
||||
});
|
||||
|
||||
function dropModelHygieneTables(): void
|
||||
{
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
}
|
||||
|
||||
function createModelHygieneTables(): void
|
||||
{
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createContainerStatusesTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createApplicationDomainsTable();
|
||||
V5TestSchema::createResourceConnectionsTable();
|
||||
V5TestSchema::createResourceConnectionRulesTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createModelHygieneServer(array $overrides = []): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.'.random_int(2, 250),
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
function createModelHygieneApplication(string $name): V5Application
|
||||
{
|
||||
return V5Application::query()->create([
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => $name,
|
||||
'image' => "docker.io/library/{$name}:latest",
|
||||
'container_name' => "coolify-v5-{$name}",
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
<?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();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createRemoveMarkerServer(array $attributes = []): V5Server
|
||||
{
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
return V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'installed',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $command
|
||||
*/
|
||||
function removeMarkerSshKeyPath(array $command): ?string
|
||||
{
|
||||
$index = array_search('-i', $command, true);
|
||||
|
||||
return $index === false ? null : ($command[$index + 1] ?? null);
|
||||
}
|
||||
|
||||
it('returns false and runs no ssh command when the server has no private key', function () {
|
||||
Process::fake();
|
||||
|
||||
$server = createRemoveMarkerServer(['private_key_id' => null]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server))->toBeFalse();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('removes the on-host bootstrap identity over ssh and deletes the temporary key file', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$server = createRemoveMarkerServer();
|
||||
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeTrue();
|
||||
|
||||
$keyPath = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$keyPath): bool {
|
||||
$command = $process->command;
|
||||
|
||||
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$script = end($command);
|
||||
|
||||
if (! str_contains($script, '$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array('root@203.0.113.10', $command, true) || ! in_array('2222', $command, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyPath = removeMarkerSshKeyPath($command);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($keyPath)->not->toBeNull()
|
||||
->and(file_exists($keyPath))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false but still deletes the temporary key file when the ssh command fails', function () {
|
||||
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
|
||||
|
||||
$server = createRemoveMarkerServer();
|
||||
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeFalse();
|
||||
|
||||
$keyPath = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$keyPath): bool {
|
||||
if (! is_array($process->command) || ($process->command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyPath = removeMarkerSshKeyPath($process->command);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($keyPath)->not->toBeNull()
|
||||
->and(file_exists($keyPath))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('uses v5 resource uuids for resource connection requests and responses', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'uuid' => 'source-application-uuid',
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'source',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'source-container',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'uuid' => 'target-application-uuid',
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'target',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'target-container',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->postJson('/v5/resource-connections', [
|
||||
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
|
||||
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('connection.fromApplicationId', $source->uuid)
|
||||
->assertJsonPath('connection.toApplicationId', $target->uuid);
|
||||
|
||||
$connectionUuid = $response->json('connection.id');
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->patchJson("/v5/resource-connections/{$connectionUuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [8080],
|
||||
],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '8080');
|
||||
|
||||
expect(ResourceConnection::query()->first())
|
||||
->resource_one_id->toBe($source->id)
|
||||
->resource_two_id->toBe($target->id);
|
||||
});
|
||||
|
||||
it('persists generic v5 resource connections and direction-specific ports', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-source',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-target',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
'_token' => 'test-csrf-token',
|
||||
])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->postJson('/v5/resource-connections', [
|
||||
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
|
||||
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('connection.applicationIds.0', $source->uuid)
|
||||
->assertJsonPath('connection.applicationIds.1', $target->uuid);
|
||||
|
||||
$connectionId = $response->json('connection.id');
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->twice()->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connectionId}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [80],
|
||||
"{$target->uuid}->{$source->uuid}" => [443],
|
||||
],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '80')
|
||||
->assertJsonPath("connection.portsByDirection.{$target->uuid}->{$source->uuid}.0", '443');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"resourceConnections":[', false)
|
||||
->assertSee("\"id\":\"{$connectionId}\"", false)
|
||||
->assertSee("\"{$source->uuid}->{$target->uuid}\":[\"80\"]", false)
|
||||
->assertSee("\"{$target->uuid}->{$source->uuid}\":[\"443\"]", false);
|
||||
});
|
||||
|
||||
it('reports flux failures when syncing v5 resource connection firewall rules', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('resolve firewall endpoint coolify-v5-api on coolify-default-mesh'));
|
||||
// The compensation pass rolls the node back by revoking the attempted rule.
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432")
|
||||
->andReturn('Firewall rule removed.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('message', 'Could not sync firewall rules through Flux.')
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->count())->toBe(0);
|
||||
|
||||
// The raw Flux/coold error is never leaked to the client, only reported server-side.
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'resolve firewall endpoint coolify-v5-api on coolify-default-mesh');
|
||||
});
|
||||
|
||||
it('syncs cross-server v5 resource connection ports on both endpoint hosts', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$sourceServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$targetServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.11',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $sourceServer->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $targetServer->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$expectedRule = [
|
||||
'id' => $firewallRuleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
];
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $expectedRule)
|
||||
->andReturn('Firewall rule applied on source host.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $expectedRule)
|
||||
->andReturn('Firewall rule applied on target host.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('syncs v5 resource connection ports through flux firewall primitives', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
$this->withoutExceptionHandling();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => $firewallRuleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
])
|
||||
->andReturn('rule-api-postgres');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $firewallRuleId)
|
||||
->andReturn('Firewall rule removed.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}")
|
||||
->assertNoContent();
|
||||
});
|
||||
|
||||
it('rejects firewall sync when a connected application has no server host id', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('still deletes a v5 resource connection whose endpoint lost its server host id', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}")
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
dropConnectionFirewallConsistencyTables();
|
||||
createConnectionFirewallConsistencyTables();
|
||||
});
|
||||
|
||||
it('restores db rules and rolls back node firewall when flux fails after the db write', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
||||
$oldRule = connectionFirewallConsistencyRule($oldRuleId, 5432);
|
||||
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
// Forward sync: revoke the removed old rule, then fail applying the new one.
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
// Compensation: revoke the attempted new rule and re-apply the restored old rule.
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $oldRule)->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('message', 'Could not sync firewall rules through Flux.')
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('keeps committed db rules and succeeds when the node coold lacks firewall support', function () {
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '6379');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([6379]);
|
||||
});
|
||||
|
||||
it('still surfaces the original flux error when the firewall rollback also fails', function () {
|
||||
Exceptions::fake();
|
||||
Log::spy();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
||||
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andThrow(new RuntimeException('revoke firewall rule: coold connection reset'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'firewall rollback failed'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('does not delete the connection when revoking firewall rules fails with a real error', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new RuntimeException('revoke firewall rule: nft exited with status 1'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The connection was not deleted. Check the server diagnostics and try again.');
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeTrue()
|
||||
->and($connection->rules()->count())->toBe(1);
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'revoke firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('reports but tolerates an unbuildable firewall snapshot when deleting the connection', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext(sourceServerless: true);
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
// The node cannot be addressed without a host id, so no flux call may happen.
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => str_contains($exception->getMessage(), 'has no reachable server host id'));
|
||||
});
|
||||
|
||||
it('deletes the connection when the node coold lacks firewall revoke support', function () {
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, int>> $portsByDirection
|
||||
*/
|
||||
function patchConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection, array $portsByDirection): mixed
|
||||
{
|
||||
return $test
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => $portsByDirection,
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection): mixed
|
||||
{
|
||||
return $test
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}
|
||||
*/
|
||||
function connectionFirewallConsistencyRule(string $ruleId, int $port): array
|
||||
{
|
||||
return [
|
||||
'id' => $ruleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-consistency-api',
|
||||
'dst' => 'coolify-v5-consistency-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => $port,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user: User, team: Team, source: V5Application, target: V5Application, connection: ResourceConnection}
|
||||
*/
|
||||
function createConnectionFirewallConsistencyContext(bool $sourceServerless = false): array
|
||||
{
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => 'margaret@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Firewall Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $sourceServerless ? null : $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-consistency-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-consistency-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'team' => $team,
|
||||
'source' => $source,
|
||||
'target' => $target,
|
||||
'connection' => $connection,
|
||||
];
|
||||
}
|
||||
|
||||
function createConnectionFirewallConsistencyTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createResourceConnectionsTable();
|
||||
V5TestSchema::createResourceConnectionRulesTable();
|
||||
}
|
||||
|
||||
function dropConnectionFirewallConsistencyTables(): void
|
||||
{
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,920 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('broadcasts v5 cluster updates when bootstrap state changes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted();
|
||||
|
||||
Event::assertDispatched(V5ClusterUpdated::class, fn ($event): bool => $event->clusterId === $cluster->id
|
||||
&& $event->teamId === $team->id);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "Bootstrap completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
expect(Event::dispatched(V5ClusterUpdated::class)->count())->toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('writes quiet ssh options for v5 bootstrap ssh config', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
]);
|
||||
$server->load('privateKey');
|
||||
|
||||
$tempDirectory = storage_path('framework/testing/v5_bootstrap_'.str()->random(8));
|
||||
mkdir($tempDirectory, 0700, true);
|
||||
|
||||
try {
|
||||
$job = new V5BootstrapServerJob($cluster->id, $server->id);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('writeBootstrapSshConfig');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$sshConfigLocation = $method->invoke($job, collect([$server]), $tempDirectory);
|
||||
$config = file_get_contents($sshConfigLocation);
|
||||
|
||||
expect($config)->toContain(' LogLevel ERROR')
|
||||
->and($config)->toContain(' UserKnownHostsFile /dev/null')
|
||||
->and($config)->toContain(' StrictHostKeyChecking no');
|
||||
} finally {
|
||||
collect(scandir($tempDirectory) ?: [])
|
||||
->reject(fn (string $file) => in_array($file, ['.', '..'], true))
|
||||
->each(fn (string $file) => @unlink($tempDirectory.'/'.$file));
|
||||
@rmdir($tempDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
it('bootstraps a single v5 server with the Coolify CLI', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
fakeAgentTokenIssuer();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
'namespaces' => ['default', 'preview'],
|
||||
'container_network_pool' => '10.211.0.0/16',
|
||||
'container_network_prefix' => 25,
|
||||
'wireguard_management_pool' => '100.65.0.0/16',
|
||||
'wireguard_interface' => 'wg-prod',
|
||||
'wireguard_listen_port' => 51830,
|
||||
'coold_version' => 'v0.2.0',
|
||||
'corrosion_version' => 'v1.1.0',
|
||||
'corrosion_gossip_port' => 8788,
|
||||
'corrosion_api_port' => 8081,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 4,
|
||||
'builder_cpu_quota' => '400%',
|
||||
'builder_memory_max' => '4G',
|
||||
'builder_timeout_secs' => 2400,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'added',
|
||||
'capabilities' => ['builder'],
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 4,
|
||||
'wireguard_listen_port_override' => 51831,
|
||||
'wireguard_endpoint_override' => 'prod-01.example.com:51831',
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Bootstrap queued.')
|
||||
->assertJsonPath('cluster.servers.0.status', 'added')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapAction', 'bootstrap')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Queued Coolify bootstrap for prod-01.');
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "Bootstrap completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = $process->command;
|
||||
$node = cliFlagValue($command, '--nodes');
|
||||
|
||||
return $command[0] === '/tmp/coolify'
|
||||
&& in_array('init', $command, true)
|
||||
&& in_array('bootstrap', $command, true)
|
||||
&& cliFlagValue($command, '--format') === 'json'
|
||||
&& in_array('--ssh-config', $command, true)
|
||||
&& cliFlagValue($command, '--ssh-user') === 'root'
|
||||
&& is_string($node)
|
||||
&& str_starts_with($node, 'v5-server-')
|
||||
&& cliFlagValue($command, '--namespaces') === 'default,preview'
|
||||
&& cliFlagValue($command, '--container-pool') === '10.211.0.0/16'
|
||||
&& cliFlagValue($command, '--wg-mgmt-pool') === '100.65.0.0/16'
|
||||
&& cliFlagValue($command, '--wg-interface') === 'wg-prod'
|
||||
&& cliFlagValue($command, '--coold-version') === 'v0.2.0'
|
||||
&& cliFlagValue($command, '--corrosion-version') === 'v1.1.0'
|
||||
&& cliFlagValue($command, '--wg-listen-port-overrides') === $node.'=51831'
|
||||
&& cliFlagValue($command, '--wg-endpoint-overrides') === $node.'=prod-01.example.com:51831'
|
||||
&& ! in_array('--enable-builder', $command, true)
|
||||
&& in_array('--yes', $command, true)
|
||||
&& ! in_array('--new-nodes', $command, true);
|
||||
});
|
||||
Process::assertRan(fn ($process): bool => in_array('bootstrap', $process->command, true) && $process->timeout === 7200);
|
||||
|
||||
$cluster->refresh();
|
||||
|
||||
expect($server->status)->toBe('installed')
|
||||
->and($server->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($server->last_bootstrap_output)->toContain('Bootstrap completed')
|
||||
->and($server->last_bootstrapped_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not run a macOS development Coolify CLI binary from Docker during v5 server bootstrap', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted();
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "Bootstrap completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify');
|
||||
});
|
||||
|
||||
it('keeps a v5 server added when Coolify CLI bootstrap fails', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('cluster.servers.0.status', 'added')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null)
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued');
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('added')
|
||||
->and($server->last_bootstrap_status)->toBe('failed')
|
||||
->and($server->last_bootstrap_output)->toBe('bootstrap failed')
|
||||
->and($server->last_bootstrapped_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('persists v5 bootstrap mesh assignments and enrolls coold into flux', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
'namespaces' => ['default', 'preview'],
|
||||
'wireguard_management_pool' => '100.65.0.0/16',
|
||||
'container_network_pool' => '10.211.0.0/16',
|
||||
'container_network_prefix' => 25,
|
||||
'coold_version' => 'v0.9.9',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'ubuntu',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$issuer = Mockery::mock(AgentTokenIssuer::class);
|
||||
$issuer
|
||||
->shouldReceive('issueForServer')
|
||||
->once()
|
||||
->with(Mockery::on(fn (V5Server $server): bool => $server->wireguard_management_ip === '100.65.0.1'))
|
||||
->andReturn('signed-host-jwt');
|
||||
app()->instance(AgentTokenIssuer::class, $issuer);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::sequence()
|
||||
->push(Process::result(output: ''))
|
||||
->push(Process::result(output: json_encode([
|
||||
'results' => [],
|
||||
'verified' => [[
|
||||
'host' => "v5-server-{$server->uuid}",
|
||||
'wireguard_ip' => '100.65.0.1',
|
||||
'peer_count' => 0,
|
||||
'status' => 'ok',
|
||||
]],
|
||||
], JSON_THROW_ON_ERROR)."\n"))
|
||||
->push(Process::result(output: "public-key\n"))
|
||||
->push(Process::result(output: "default=10.211.0.0/25\npreview=10.211.0.128/25\n"))
|
||||
->push(Process::result(output: "marker written\n"))
|
||||
->push(Process::result(output: "coold restarted\n")),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->wireguard_management_ip)->toBe('100.65.0.1')
|
||||
->and($server->wireguard_public_key)->toBe('public-key')
|
||||
->and($server->container_subnets)->toBe([
|
||||
'default' => '10.211.0.0/25',
|
||||
'preview' => '10.211.0.128/25',
|
||||
])
|
||||
->and($server->node_address)->toBe('100.65.0.1')
|
||||
->and($server->status)->toBe('installed')
|
||||
->and($server->coold_version)->toBe('v0.9.9')
|
||||
->and($server->last_bootstrapped_at)->not->toBeNull();
|
||||
|
||||
Process::assertRan(function ($process) use ($server): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
return str_contains($command, 'COOLIFY_COOLD_FLUX_URL=http://coolify.example.com:6443')
|
||||
&& str_contains($command, 'COOLIFY_COOLD_HOST_ID='.$server->uuid)
|
||||
&& str_contains($command, 'COOLIFY_COOLD_HOST_JWT_PATH=/etc/coolify/host-jwt')
|
||||
&& str_contains($command, 'signed-host-jwt')
|
||||
&& str_contains($command, 'chmod 600 /etc/coolify/host-jwt')
|
||||
&& str_contains($command, 'systemctl restart coold.service');
|
||||
});
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
return str_contains($command, 'wg show')
|
||||
&& str_contains($command, "if [ \"$(id -u)\" != \"0\" ]; then SUDO='sudo'; fi");
|
||||
});
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
return str_contains($command, 'podman network inspect "coolify-${ns}-mesh"')
|
||||
&& str_contains($command, "if [ \"$(id -u)\" != \"0\" ]; then SUDO='sudo'; fi");
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the bootstrapped host to connect to flux before marking the server installed', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
Config::set('flux.bootstrap_host_connection_timeout_seconds', 1);
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'wireguard_management_pool' => '100.65.0.0/16',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'ubuntu',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'added',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
fakeAgentTokenIssuer();
|
||||
$this->mock(FluxClient::class, function ($mock) use ($server): void {
|
||||
$mock->shouldReceive('cooldLogs')
|
||||
->once()
|
||||
->with($server->uuid, 1)
|
||||
->andReturn('coold connected');
|
||||
});
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::sequence()
|
||||
->push(Process::result(output: ''))
|
||||
->push(Process::result(output: json_encode([
|
||||
'verified' => [[
|
||||
'host' => "v5-server-{$server->uuid}",
|
||||
'wireguard_ip' => '100.65.0.1',
|
||||
]],
|
||||
], JSON_THROW_ON_ERROR)."\n"))
|
||||
->push(Process::result(output: "public-key\n"))
|
||||
->push(Process::result(output: "default=10.211.0.0/25\n"))
|
||||
->push(Process::result(output: "marker written\n"))
|
||||
->push(Process::result(output: "coold restarted\n")),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('installed')
|
||||
->and($server->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($server->wireguard_management_ip)->toBe('100.65.0.1');
|
||||
});
|
||||
|
||||
it('reads the WireGuard management IP from the host when bootstrap output does not include it', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'wireguard_interface' => 'wg-prod',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'ubuntu',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'added',
|
||||
'node_address' => '203.0.113.10',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$issuer = Mockery::mock(AgentTokenIssuer::class);
|
||||
$issuer
|
||||
->shouldReceive('issueForServer')
|
||||
->once()
|
||||
->with(Mockery::on(fn (V5Server $server): bool => $server->wireguard_management_ip === '100.65.0.1'))
|
||||
->andReturn('signed-host-jwt');
|
||||
app()->instance(AgentTokenIssuer::class, $issuer);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::sequence()
|
||||
->push(Process::result(output: ''))
|
||||
->push(Process::result(output: json_encode([
|
||||
'results' => [],
|
||||
'verified' => [],
|
||||
], JSON_THROW_ON_ERROR)."\n"))
|
||||
->push(Process::result(output: "100.65.0.1\n"))
|
||||
->push(Process::result(output: "public-key\n"))
|
||||
->push(Process::result(output: "default=10.211.0.0/25\n"))
|
||||
->push(Process::result(output: "marker written\n"))
|
||||
->push(Process::result(output: "coold restarted\n")),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->wireguard_management_ip)->toBe('100.65.0.1')
|
||||
->and($server->node_address)->toBe('100.65.0.1')
|
||||
->and($server->status)->toBe('installed');
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
return str_contains($command, 'ip -4 -o addr show dev')
|
||||
&& str_contains($command, 'wg-prod');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails the bootstrap when the flux url is not configured', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', '');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "Bootstrap completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('added')
|
||||
->and($server->last_bootstrap_status)->toBe('failed')
|
||||
->and($server->last_bootstrap_output)->toContain('COOLIFY_COOLD_FLUX_URL')
|
||||
->and($server->last_bootstrapped_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('writes the bootstrap marker before flux enrollment so a failed enrollment can resume', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
fakeAgentTokenIssuer();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*systemctl restart coold*' => Process::result(errorOutput: "enrollment boom\n", exitCode: 1),
|
||||
'*' => Process::result(output: "Bootstrap completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('added')
|
||||
->and($server->last_bootstrap_status)->toBe('failed')
|
||||
->and($server->last_bootstrap_output)->toContain('enrollment boom')
|
||||
->and($server->last_bootstrap_output)->toContain('retrying this bootstrap is safe')
|
||||
->and($server->last_bootstrapped_at)->toBeNull();
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
return str_contains($command, 'v5-node.json') && str_contains($command, 'base64 -d');
|
||||
});
|
||||
});
|
||||
|
||||
it('exits the bootstrap job when its claim was superseded', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake();
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
Process::assertNothingRan();
|
||||
expect($server->refresh()->last_bootstrap_status)->toBe('failed');
|
||||
});
|
||||
|
||||
it('recovers a stale queued v5 bootstrap claim', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$staleServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now()->subMinutes(20),
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-02',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Bootstrap queued.');
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class);
|
||||
|
||||
expect($staleServer->refresh()->last_bootstrap_status)->toBe('failed')
|
||||
->and($staleServer->last_bootstrap_output)->toContain('timed out or its worker died')
|
||||
->and($server->refresh()->last_bootstrap_status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('extends a v5 cluster when bootstrapping a new server', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
fakeAgentTokenIssuer();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$oldPrivateKey = createV5PrivateKey($team, 'Old SSH Key');
|
||||
$newPrivateKey = createV5PrivateKey($team, 'New SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
'coold_version' => 'v0.3.0',
|
||||
'corrosion_version' => 'v1.2.0',
|
||||
]);
|
||||
$oldServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $oldPrivateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'ubuntu',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'installed',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrapped_at' => now()->subDay(),
|
||||
]);
|
||||
$newServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $newPrivateKey->id,
|
||||
'name' => 'prod-02',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
]);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$newServer->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('cluster.servers.1.lastBootstrapAction', 'extend')
|
||||
->assertJsonPath('cluster.servers.1.lastBootstrapStatus', 'queued');
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: "Extend completed\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $newServer->id))->handle();
|
||||
|
||||
Process::assertRan(function ($process) use ($oldServer, $newServer): bool {
|
||||
$command = $process->command;
|
||||
$oldNode = "v5-server-{$oldServer->uuid}";
|
||||
$newNode = "v5-server-{$newServer->uuid}";
|
||||
|
||||
return in_array('extend', $command, true)
|
||||
&& in_array("{$oldNode},{$newNode}", $command, true)
|
||||
&& in_array('--new-nodes', $command, true)
|
||||
&& in_array($newNode, $command, true)
|
||||
&& cliFlagValue($command, '--ssh-user') === 'root'
|
||||
&& in_array('--ssh-config', $command, true)
|
||||
&& cliFlagValue($command, '--coold-version') === 'v0.3.0'
|
||||
&& cliFlagValue($command, '--corrosion-version') === 'v1.2.0';
|
||||
});
|
||||
|
||||
$oldServer->refresh();
|
||||
$newServer->refresh();
|
||||
|
||||
expect($oldServer->status)->toBe('installed')
|
||||
->and($newServer->status)->toBe('installed')
|
||||
->and($newServer->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($newServer->last_bootstrap_output)->toContain('Extend completed')
|
||||
->and($newServer->last_bootstrapped_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('adopts a re-added v5 server that is already bootstrapped for the same cluster', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
fakeAgentTokenIssuer();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: json_encode([
|
||||
'cluster_id' => $cluster->id,
|
||||
'cluster_uuid' => $cluster->uuid,
|
||||
'server_uuid' => 'server-previous-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'wireguard_public_key' => 'public-key',
|
||||
'container_subnets' => ['default' => '10.210.0.0/24'],
|
||||
], JSON_THROW_ON_ERROR)."\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('installed')
|
||||
->and($server->uuid)->toBe('server-previous-uuid')
|
||||
->and($server->wireguard_management_ip)->toBe('100.64.0.10')
|
||||
->and($server->wireguard_public_key)->toBe('public-key')
|
||||
->and($server->container_subnets)->toBe(['default' => '10.210.0.0/24'])
|
||||
->and($server->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($server->last_bootstrap_output)->toBe('Adopted existing Coolify bootstrap state for this cluster.')
|
||||
->and($server->last_bootstrapped_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks bootstrapping a v5 server that belongs to another cluster', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
Process::fake([
|
||||
'*' => Process::result(output: json_encode([
|
||||
'cluster_id' => $cluster->id + 100,
|
||||
'server_uuid' => 'server-other-uuid',
|
||||
], JSON_THROW_ON_ERROR)."\n"),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->status)->toBe('added')
|
||||
->and($server->last_bootstrap_status)->toBe('failed')
|
||||
->and($server->last_bootstrap_output)->toContain('already bootstrapped for another cluster')
|
||||
->and($server->last_bootstrapped_at)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('broadcasts v5 canvas resource updates when application state changes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
'canvas_x' => 0,
|
||||
'canvas_y' => 0,
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Container stopped.',
|
||||
]);
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('broadcasts the full v5 application canvas shape after application state changes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 80,
|
||||
'canvas_x' => 0,
|
||||
'canvas_y' => 0,
|
||||
]);
|
||||
V5ApplicationDomain::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'domain' => 'nginx.example.com',
|
||||
]);
|
||||
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Container stopped.',
|
||||
]);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||
|
||||
expect($payload['application'])
|
||||
->toMatchArray([
|
||||
'id' => $application->uuid,
|
||||
'status' => 'exited',
|
||||
'statusMessage' => 'Container stopped.',
|
||||
'effectiveStatus' => 'exited',
|
||||
'effectiveStatusMessage' => 'Container stopped.',
|
||||
'serverName' => 'edge-01',
|
||||
'serverStatus' => 'installed',
|
||||
'isServerReachable' => true,
|
||||
'serverIngressEnabled' => true,
|
||||
'ingressEnabled' => true,
|
||||
'internalPort' => 80,
|
||||
'domains' => ['nginx.example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('broadcasts v5 application status as unknown when its server is unreachable', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'unreachable',
|
||||
'last_status_output' => 'coold heartbeat timed out.',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
]);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||
|
||||
expect($payload['application'])
|
||||
->toMatchArray([
|
||||
'status' => 'running',
|
||||
'effectiveStatus' => 'unknown',
|
||||
'effectiveStatusMessage' => 'coold heartbeat timed out.',
|
||||
'serverStatus' => 'unreachable',
|
||||
'isServerReachable' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Cluster',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server->update(['status' => 'unreachable']);
|
||||
|
||||
Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->clusterId === $cluster->id);
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->caddyIngressServerId === $server->id);
|
||||
});
|
||||
|
||||
it('broadcasts v5 cluster updates with uuid identifiers and the full server shape', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'uuid' => 'cluster-public-uuid',
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'uuid' => 'server-public-uuid',
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['ingress'],
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'ingress_type' => 'caddy',
|
||||
'last_status_output' => 'coold heartbeat ok.',
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$payload = (new V5ClusterUpdated($team->id, $cluster->id))->broadcastWith();
|
||||
|
||||
expect($payload['cluster']['id'])
|
||||
->toBe($cluster->uuid)
|
||||
->and($payload['cluster']['id'])->not->toBe((string) $cluster->id)
|
||||
->and($payload['cluster']['servers'][0]['id'])->not->toBe((string) $server->id)
|
||||
->and($payload['cluster']['servers'][0])->toMatchArray([
|
||||
'id' => $server->uuid,
|
||||
'uuid' => $server->uuid,
|
||||
'ingressEnabled' => true,
|
||||
'ingressType' => 'caddy',
|
||||
'lastStatusOutput' => 'coold heartbeat ok.',
|
||||
])
|
||||
->and($payload['cluster']['servers'][0])->toHaveKey('lastStatusCheckedAt');
|
||||
});
|
||||
|
||||
it('resolves an empty v5 canvas broadcast payload when the models are deleted before the queued broadcast runs', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated(1, 999, 998, 997))->broadcastWith();
|
||||
|
||||
expect($payload)->toBe([
|
||||
'application' => null,
|
||||
'applications' => [],
|
||||
'caddyIngress' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a null v5 cluster broadcast payload when the cluster is deleted before the queued broadcast runs', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$payload = (new V5ClusterUpdated(1, 999))->broadcastWith();
|
||||
|
||||
expect($payload)->toBe(['cluster' => null]);
|
||||
});
|
||||
|
||||
it('broadcasts v5 canvas updates only after the database transaction commits', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'creating',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
DB::transaction(function () use ($application): void {
|
||||
$application->update(['status' => 'running']);
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Database\Seeders\V5DevLimaSeeder;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('syncs dev Lima VMs into v5 clusters and servers', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
|
||||
|
||||
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
|
||||
'--team-id' => $team->id,
|
||||
'--user-id' => $user->id,
|
||||
'--cluster' => 'Development-Lima',
|
||||
'--server' => [
|
||||
'coold-dev|host.docker.internal|developer|61332|100.64.0.1',
|
||||
'coold-dev-2|host.docker.internal|developer|61379|100.64.0.2',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1)
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('updates legacy dev Lima hostnames to Docker reachable SSH endpoints', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'lima-coold-dev',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 2,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
|
||||
'--team-id' => $team->id,
|
||||
'--user-id' => $user->id,
|
||||
'--cluster' => 'Development-Lima',
|
||||
'--server' => [
|
||||
'coold-dev|host.docker.internal|developer|61332',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('host', 'lima-coold-dev')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('seeds dev Lima VMs into v5 clusters and servers idempotently', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5PrivateKey($team, 'Dev Lima Key');
|
||||
|
||||
(new V5DevLimaSeeder)->run();
|
||||
(new V5DevLimaSeeder)->run();
|
||||
|
||||
$cluster = Cluster::query()->where('name', 'Development-Lima')->sole();
|
||||
|
||||
expect($cluster->team_id)->toBe($team->id)
|
||||
->and($cluster->created_by_user_id)->toBe($user->id)
|
||||
->and($cluster->description)->toBe('Local Lima development cluster managed by scripts/dev.sh.')
|
||||
->and(V5Server::query()->count())->toBe(2)
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'coold-dev.local')->where('ssh_user', 'coolify')->where('ssh_port', 22)->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'coold-dev-2.local')->where('ssh_user', 'coolify')->where('ssh_port', 22)->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('name', 'coold-dev-2')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->exists())->toBeTrue()
|
||||
->and(V5Server::query()->where('status', 'installed')->count())->toBe(2)
|
||||
->and(V5Server::query()->where('builder_enabled', false)->where('builder_capacity', 0)->count())->toBe(2)
|
||||
->and(V5Server::query()->where('cluster_id', $cluster->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('seeds dev Lima VMs by updating existing named servers', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5PrivateKey($team, 'Dev Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'old-host.local',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrapped_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
(new V5DevLimaSeeder)->run();
|
||||
|
||||
expect(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
|
||||
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'coold-dev.local')->where('ssh_port', 22)->exists())->toBeTrue()
|
||||
->and(V5Server::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('configures v5 dev lima host resolver for coolify internal dns', function () {
|
||||
$script = file_get_contents(base_path('scripts/coold-vm.sh'));
|
||||
|
||||
expect($script)
|
||||
->toContain('configure_system_resolved')
|
||||
->toContain('ensure_mesh_dns_anchor')
|
||||
->toContain('coolify-v5-mesh-dns-anchor')
|
||||
->toContain('resolvectl dns podman1 "$CONTAINER_GATEWAY"')
|
||||
->toContain("resolvectl domain podman1 '~coolify.internal'")
|
||||
->toContain('resolvectl default-route podman1 false');
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('applies flux application status updates to the database and broadcasts to the team canvas', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
'canvas_x' => 0,
|
||||
'canvas_y' => 0,
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Status received from coold through flux.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Application::class)
|
||||
->and($application->refresh()->status)->toBe('exited')
|
||||
->and($application->status_message)->toBe('Status received from coold through flux.')
|
||||
->and($application->runtime_container_id)->toBe('nginx-container-id');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('ignores stale flux status updates for a superseded container', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'new-container-id',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'old-container-id',
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Stale event for the old container.',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe('running')
|
||||
->and($application->runtime_container_id)->toBe('new-container-id');
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('scopes flux application status updates to the reporting server', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$userA, $teamA] = createV5UserWithTeam();
|
||||
[$userB, $teamB] = createV5UserWithTeam('other@example.com');
|
||||
[$projectA, $environmentA] = createV5ProjectWithEnvironment($teamA, 'Project A', 'Production');
|
||||
[$projectB, $environmentB] = createV5ProjectWithEnvironment($teamB, 'Project B', 'Production');
|
||||
$serverA = V5Server::query()->create([
|
||||
'team_id' => $teamA->id,
|
||||
'created_by_user_id' => $userA->id,
|
||||
'name' => 'edge-a',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$serverB = V5Server::query()->create([
|
||||
'team_id' => $teamB->id,
|
||||
'created_by_user_id' => $userB->id,
|
||||
'name' => 'edge-b',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.6',
|
||||
]);
|
||||
$applicationA = V5Application::query()->create([
|
||||
'team_id' => $teamA->id,
|
||||
'project_id' => $projectA->id,
|
||||
'environment_id' => $environmentA->id,
|
||||
'server_id' => $serverA->id,
|
||||
'created_by_user_id' => $userA->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api-a',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$applicationB = V5Application::query()->create([
|
||||
'uuid' => $applicationA->uuid.'-b',
|
||||
'team_id' => $teamB->id,
|
||||
'project_id' => $projectB->id,
|
||||
'environment_id' => $environmentB->id,
|
||||
'server_id' => $serverB->id,
|
||||
'created_by_user_id' => $userB->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api-b',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
// An update reported by team A's host must never resolve to team B's app,
|
||||
// even when the payload carries team B's application uuid.
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'application_uuid' => $applicationB->uuid,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeNull()
|
||||
->and($applicationA->refresh()->status)->toBe('running')
|
||||
->and($applicationB->refresh()->status)->toBe('running');
|
||||
});
|
||||
|
||||
it('ignores flux application status updates whose host does not resolve to a server', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.99.99.99',
|
||||
'application_uuid' => $application->uuid,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeNull()
|
||||
->and($application->refresh()->status)->toBe('running');
|
||||
});
|
||||
|
||||
it('maps generic flux container status updates to v5 applications', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Container state received from coold.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Application::class)
|
||||
->and($application->refresh()->status)->toBe('exited')
|
||||
->and($application->status_message)->toBe('Container state received from coold.');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('applies flux ingress server status updates to the database and broadcasts cluster plus canvas updates', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Cluster',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['ingress'],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'message' => 'coold heartbeat timed out.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Server::class)
|
||||
->and($server->refresh()->status)->toBe('unreachable')
|
||||
->and($server->last_status_check)->toBe('flux')
|
||||
->and($server->last_status_output)->toBe('coold heartbeat timed out.')
|
||||
->and($server->last_status_checked_at)->not->toBeNull();
|
||||
|
||||
Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->clusterId === $cluster->id);
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->caddyIngressServerId === $server->id);
|
||||
});
|
||||
|
||||
it('ignores flux server status updates when a host id matches multiple v5 servers', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Cluster',
|
||||
]);
|
||||
$serverOne = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$serverTwo = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'message' => 'ambiguous host id.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeNull()
|
||||
->and($serverOne->refresh()->status)->toBe('installed')
|
||||
->and($serverTwo->refresh()->status)->toBe('installed');
|
||||
|
||||
Event::assertNotDispatched(V5ClusterUpdated::class);
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('broadcasts v5 canvas application updates when a non-ingress server goes unreachable', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Cluster',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'worker-01',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.6',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.6',
|
||||
'status' => 'unreachable',
|
||||
'message' => 'coold heartbeat timed out.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Server::class)
|
||||
->and($server->refresh()->status)->toBe('unreachable');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->serverId === $server->id);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($team->id, serverId: $server->id))->broadcastWith();
|
||||
expect($payload['applications'])
|
||||
->toHaveCount(1)
|
||||
->and($payload['applications'][0])
|
||||
->toMatchArray([
|
||||
'id' => $application->uuid,
|
||||
'status' => 'running',
|
||||
'effectiveStatus' => 'unknown',
|
||||
'effectiveStatusMessage' => 'coold heartbeat timed out.',
|
||||
'serverStatus' => 'unreachable',
|
||||
'isServerReachable' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies flux caddy ingress container status updates without changing server install status', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'ingress_status' => 'running',
|
||||
'capabilities' => ['ingress'],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'caddy_ingress',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-caddy',
|
||||
'status' => 'exited',
|
||||
'message' => 'Caddy container exited.',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Server::class)
|
||||
->and($server->refresh()->status)->toBe('installed')
|
||||
->and($server->ingress_type)->toBe('caddy')
|
||||
->and($server->ingress_status)->toBe('exited')
|
||||
->and($server->last_status_check)->toBe('flux')
|
||||
->and($server->last_status_output)->toBe('Caddy container exited.')
|
||||
->and($server->last_status_checked_at)->not->toBeNull();
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->caddyIngressServerId === $server->id);
|
||||
});
|
||||
|
||||
it('accepts flux status updates for non coolify managed containers', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
|
||||
$this
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'running',
|
||||
], [
|
||||
'Authorization' => 'Bearer test-flux-token',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('message', 'Resource status updated.');
|
||||
|
||||
expect(ContainerStatus::query()
|
||||
->where('container_id', 'external-container-id')
|
||||
->where('container_name', 'external-container')
|
||||
->where('status', 'running')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects flux resource status http updates without the shared token', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
$this
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'status' => 'running',
|
||||
])
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('accepts flux resource status http updates identified by resource uuids', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'uuid' => 'server-public-uuid',
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'uuid' => 'application-public-uuid',
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'starting',
|
||||
'status_message' => 'Container starting.',
|
||||
]);
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'server_uuid' => $server->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
'container_id' => 'new-container-id',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Status received from coold through flux.',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('message', 'Resource status updated.');
|
||||
|
||||
expect($application->refresh()->status)->toBe('running')
|
||||
->and($application->runtime_container_id)->toBe('new-container-id');
|
||||
});
|
||||
|
||||
it('rejects numeric laravel ids in flux resource status http payloads', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'server_id' => 1,
|
||||
'application_id' => 1,
|
||||
'status' => 'running',
|
||||
])
|
||||
->assertInvalid(['server_id', 'application_id']);
|
||||
});
|
||||
|
||||
it('accepts flux resource status http updates and stores them in the database', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'starting',
|
||||
'status_message' => 'Container starting.',
|
||||
'runtime_container_id' => 'old-container-id',
|
||||
]);
|
||||
|
||||
Event::fake([V5CanvasResourceUpdated::class]);
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'old-container-id',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Status received from coold through flux.',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('message', 'Resource status updated.');
|
||||
|
||||
expect($application->refresh()->status)->toBe('running')
|
||||
->and($application->status_message)->toBe('Status received from coold through flux.')
|
||||
->and($application->runtime_container_id)->toBe('old-container-id');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||
&& $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('configures flux resource status updates for local http instead of redis', function () {
|
||||
$configSource = file_get_contents(config_path('flux.php'));
|
||||
|
||||
expect($configSource)
|
||||
->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN')
|
||||
->not->toContain('APP_KEY')
|
||||
->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL')
|
||||
->not->toContain('resource_status_channel');
|
||||
});
|
||||
@@ -0,0 +1,932 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('keeps long v5 application metadata inside the canvas card', function () {
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
$caddyIngressCardSource = file_get_contents(resource_path('js/v5/components/canvas/caddy-ingress-card.tsx'));
|
||||
|
||||
expect($caddyIngressCardSource)
|
||||
->toContain('overflow-hidden')
|
||||
->and($applicationCardSource)->toContain('grid grid-cols-[auto_minmax(0,1fr)]')
|
||||
->and($applicationCardSource)->toContain('truncate text-right font-medium')
|
||||
->and($applicationCardSource)->toContain('truncate text-right font-mono');
|
||||
});
|
||||
|
||||
it('does not render v5 application status messages on dashboard cards', function () {
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
expect($applicationCardSource)
|
||||
->not->toContain('{application.statusMessage && (')
|
||||
->not->toContain('{application.statusMessage}</p>');
|
||||
});
|
||||
|
||||
it('lets users dismiss v5 dashboard notices', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$noticeSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-notice.tsx'));
|
||||
|
||||
expect($noticeSource)
|
||||
->toContain('aria-label="Dismiss notice"')
|
||||
->toContain('onClick={onDismiss}')
|
||||
->toContain('<span className="sr-only">Dismiss notice</span>')
|
||||
->toContain('bg-card')
|
||||
->not->toContain('bg-destructive/10')
|
||||
->not->toContain('bg-emerald-500/10')
|
||||
->not->toContain('bg-blue-500/10');
|
||||
|
||||
expect($dashboardSource)->toContain('onDismiss={dismissNotice}');
|
||||
});
|
||||
|
||||
it('uses the shared dialog and button components for the application ingress modal', function () {
|
||||
$ingressDialogSource = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
|
||||
|
||||
expect($ingressDialogSource)
|
||||
->toContain('<Dialog')
|
||||
->toContain('open')
|
||||
->toContain('<DialogContent')
|
||||
->toContain('showCloseButton')
|
||||
->toContain('<Button type="submit" variant="coolify"')
|
||||
->not->toContain('>Cancel</button>')
|
||||
->not->toContain('>Close</button>');
|
||||
});
|
||||
|
||||
it('shows a loading state while toggling application ingress', function () {
|
||||
$ingressHookSource = file_get_contents(resource_path('js/v5/lib/use-application-ingress.ts'));
|
||||
$ingressButtonSource = file_get_contents(resource_path('js/v5/components/canvas/application-ingress-button.tsx'));
|
||||
$ingressDialogSource = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
|
||||
|
||||
expect($ingressHookSource)
|
||||
->toContain('const savingIngressApplications = usePendingIds<string>()')
|
||||
->toContain('savingIngressApplications.start(application.id)')
|
||||
->toContain('savingIngressApplications.finish(application.id)');
|
||||
|
||||
expect($ingressButtonSource)->toContain("isSaving ? 'Saving...'");
|
||||
|
||||
expect($ingressDialogSource)->toContain("isSaving ? 'Saving...' : 'Enable ingress'");
|
||||
});
|
||||
|
||||
it('shows a dashboard refresh button next to the center button', function () {
|
||||
$toolbarSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
|
||||
expect($toolbarSource)
|
||||
->toContain('onClick={onCenter}')
|
||||
->toContain('onClick={onRefresh}')
|
||||
->toContain("isRefreshing ? 'Refreshing…' : 'Refresh state'");
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('centerOnCanvasNodes(applications, ingresses)')
|
||||
->toContain('void refreshApplications()');
|
||||
});
|
||||
|
||||
it('subscribes the v5 dashboard canvas to automatic resource status updates', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$channelHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-channel.ts'));
|
||||
$teamChannelSource = file_get_contents(resource_path('js/v5/lib/use-team-channel.ts'));
|
||||
|
||||
$mergeHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-resource-merge.ts'));
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('currentTeam = null')
|
||||
->toContain('useCanvasResourceMerge({')
|
||||
->toContain('teamId: currentTeam?.id ?? null')
|
||||
->not->toContain("fetch('/v5/canvas-state'");
|
||||
|
||||
expect($mergeHookSource)
|
||||
->toContain('setApplications((currentApplications) =>')
|
||||
->toContain('setIngresses((currentIngresses) =>')
|
||||
->toContain('useCanvasResourceChannel(teamId, handleCanvasResourceEvent)');
|
||||
|
||||
expect($channelHookSource)
|
||||
->toContain("useTeamChannel(teamId, '.v5.canvas.resource.updated'");
|
||||
|
||||
expect($teamChannelSource)
|
||||
->toContain('channel.listen(eventName')
|
||||
->toContain('Waiting for window.Echo before subscribing to ${eventName} updates');
|
||||
});
|
||||
|
||||
it('allows zooming the v5 dashboard canvas with buttons and pinch gestures', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$viewportHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-viewport.ts'));
|
||||
$toolbarSource = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
|
||||
|
||||
expect($viewportHookSource)
|
||||
->toContain('zoom: number;')
|
||||
->toContain('MIN_CANVAS_ZOOM')
|
||||
->toContain('MAX_CANVAS_ZOOM')
|
||||
->toContain('PINCH_CANVAS_ZOOM_STEP')
|
||||
->toContain('zoomCanvas(')
|
||||
->toContain('zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP')
|
||||
->toContain('event.ctrlKey');
|
||||
|
||||
expect($toolbarSource)
|
||||
->toContain('aria-label="Zoom out"')
|
||||
->toContain('aria-label="Zoom in"')
|
||||
->toContain('Math.round(zoom * 100)');
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('onWheel={handleCanvasWheel}')
|
||||
->toContain('scale(${viewport.zoom})');
|
||||
});
|
||||
|
||||
it('renders draggable connector dots on v5 application canvas cards', function () {
|
||||
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
$connectionLinesSource = file_get_contents(resource_path('js/v5/components/canvas/connection-lines.tsx'));
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
|
||||
expect($geometrySource)->toContain("export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';");
|
||||
|
||||
expect($applicationCardSource)
|
||||
->toContain('application-connector')
|
||||
->toContain('data-connector-side={side}')
|
||||
->toContain('onConnectorPointerDown(event, application.id, side)');
|
||||
|
||||
expect($connectionLinesSource)->toContain('<svg className="pointer-events-none absolute inset-0 overflow-visible">');
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('onConnectorPointerDown={startConnectionDrag}')
|
||||
->toContain('draftConnection');
|
||||
});
|
||||
|
||||
it('keeps v5 canvas connections selectable unique and shortest-path only', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$connectionsHookSource = file_get_contents(resource_path('js/v5/lib/use-canvas-connections.ts'));
|
||||
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
|
||||
$connectionLinesSource = file_get_contents(resource_path('js/v5/components/canvas/connection-lines.tsx'));
|
||||
$portsEditorSource = file_get_contents(resource_path('js/v5/components/canvas/connection-ports-editor.tsx'));
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('resourceConnections: initialResourceConnections = []')
|
||||
->toContain('selectedConnectionId')
|
||||
->toContain('clearCanvasSelection')
|
||||
->toContain('event.target !== event.currentTarget')
|
||||
->toContain('connectionExists')
|
||||
->toContain("closest<HTMLElement>('[data-application-card]')")
|
||||
->toContain('persistNewConnection(pointerState.from.applicationId, targetApplicationId)');
|
||||
|
||||
expect($connectionsHookSource)
|
||||
->toContain('type CanvasConnection = V5ResourceConnection;')
|
||||
->toContain('useState<CanvasConnection[]>(initialConnections)')
|
||||
->toContain("!['Backspace', 'Delete'].includes(event.key)")
|
||||
->toContain('rollback: () =>')
|
||||
->toContain('persistConnectionPorts(updatedConnection, connection)')
|
||||
->toContain('/v5/resource-connections')
|
||||
->toContain('ports_by_direction: portsByDirection')
|
||||
->toContain('connectionDirectionKey(')
|
||||
->toContain('pruneConnectionPortsByDirection(')
|
||||
->toContain('Number.isInteger(portNumber)')
|
||||
->toContain('setConnectionPortInput');
|
||||
|
||||
expect($geometrySource)->toContain('shortestConnectionPoints');
|
||||
|
||||
expect($connectionLinesSource)
|
||||
->toContain('onClick={(event) => onSelect(event, connection.id)}')
|
||||
->toContain("isSelected ? 'stroke-destructive' : 'stroke-warning'")
|
||||
->toContain('aria-label="Select connection"')
|
||||
->toContain('stroke="transparent"')
|
||||
->toContain('strokeWidth={12}')
|
||||
->toContain('id="dashboard-connection-arrow"')
|
||||
->toContain('markerEnd={isSelected ? \'url(#dashboard-connection-arrow)\' : undefined}')
|
||||
->toContain('markerWidth="16"')
|
||||
->toContain('markerHeight="16"')
|
||||
->toContain('strokeDasharray="6 6"');
|
||||
|
||||
expect($portsEditorSource)
|
||||
->toContain('activeConnectionPorts(connection)')
|
||||
->toContain('onAddPort(connection.id)')
|
||||
->toContain('Allowed ports')
|
||||
->toContain('onUpdateDirection(')
|
||||
->toContain('applicationDirectionLabel(')
|
||||
->toContain('application.id.slice(0, 8)')
|
||||
->toContain('connection.applicationIds[0]')
|
||||
->toContain('connection.applicationIds[1]')
|
||||
->toContain('left: (points.from.x + points.to.x) / 2')
|
||||
->toContain('top: (points.from.y + points.to.y) / 2')
|
||||
->toContain('onDelete(connection.id)')
|
||||
->toContain('Delete connection');
|
||||
|
||||
expect($applicationCardSource)
|
||||
->toContain('data-application-card="application-card"')
|
||||
->toContain('group/application')
|
||||
->toContain('opacity-0')
|
||||
->toContain('group-hover/application:opacity-100');
|
||||
});
|
||||
|
||||
it('shows v5 application connector dots after selecting a canvas card', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('selectedApplicationId')
|
||||
->toContain('setSelectedApplicationId(application.id)')
|
||||
->toContain('selectedApplicationId === application.id');
|
||||
|
||||
expect($applicationCardSource)->toContain('opacity-100');
|
||||
});
|
||||
|
||||
it('keeps side connector geometry aligned with the rendered v5 application card height', function () {
|
||||
$geometrySource = file_get_contents(resource_path('js/v5/lib/canvas-geometry.ts'));
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
expect($geometrySource)->toContain('const APPLICATION_CARD_HEIGHT = 160;');
|
||||
|
||||
expect($applicationCardSource)->toContain('h-40 w-80');
|
||||
});
|
||||
|
||||
it('shows a loading state on v5 application delete buttons', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
foreach ([
|
||||
'const deletingApplications = usePendingIds<string>()',
|
||||
'deletingApplications.start(application.id)',
|
||||
'deletingApplications.finish(application.id)',
|
||||
'isDeleting={deletingApplications.pendingIds.has(application.id)}',
|
||||
] as $expectedSource) {
|
||||
$this->assertTrue(str_contains($dashboardSource, $expectedSource), "Missing source: {$expectedSource}");
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'disabled={isDeleting}',
|
||||
"{isDeleting ? 'Deleting…' : 'Delete'}",
|
||||
] as $expectedSource) {
|
||||
$this->assertTrue(str_contains($applicationCardSource, $expectedSource), "Missing source: {$expectedSource}");
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a larger mobile touch target for v5 application connector dots', function () {
|
||||
$applicationCardSource = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
|
||||
expect($applicationCardSource)
|
||||
->toContain('size-8')
|
||||
->toContain('md:size-3')
|
||||
->toContain('group/connector')
|
||||
->toContain('group-hover/connector:scale-125')
|
||||
->toContain('<span className="size-3 rounded-full border-2 border-card bg-warning shadow ring-2 ring-background transition group-hover/connector:scale-125 group-hover/connector:bg-warning/90" />');
|
||||
});
|
||||
|
||||
it('detects the connection drop target from pointer coordinates for mobile drags', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
|
||||
expect($dashboardSource)
|
||||
->toContain('connectionTargetFromPointer(event)')
|
||||
->toContain('document.elementFromPoint(event.clientX, event.clientY)')
|
||||
->toContain('pointer-captured mobile drags')
|
||||
->toContain('targetApplicationId !== pointerState.from.applicationId');
|
||||
});
|
||||
|
||||
it('renders coold diagnostics actions in the v5 server menu', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain('Coold logs')
|
||||
->toContain('Corrosion tables')
|
||||
->toContain('Firewall rules')
|
||||
->toContain('/coold-logs?tail=200')
|
||||
->toContain('/corrosion-tables?limit=200')
|
||||
->toContain('/firewall-rules')
|
||||
->toContain("source: 'flux' | 'ssh';")
|
||||
->toContain('setCooldLogsSource(payload?.source ?? null)')
|
||||
->toContain('setCorrosionTablesSource(payload?.source ?? null)')
|
||||
->toContain('Source: {diagnosticsSourceLabel(cooldLogsSource)}')
|
||||
->toContain('Source: {diagnosticsSourceLabel(corrosionTablesSource)}')
|
||||
->toContain('Latest journalctl entries')
|
||||
->toContain('Corrosion table snapshots')
|
||||
->toContain('Defined coold allow rules')
|
||||
->toContain('overflow-y-auto overflow-x-hidden')
|
||||
->toContain('whitespace-pre-wrap wrap-anywhere');
|
||||
});
|
||||
|
||||
it('renders v5 server status on cluster server cards', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain('server.status')
|
||||
->toContain('server.lastStatusOutput')
|
||||
->toContain('statusLabel(server.status)')
|
||||
->toContain('statusBadgeClass(server.status)');
|
||||
});
|
||||
|
||||
it('broadcasts v5 cluster updates from the queue with an explicit echo event name', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
$teamChannelSource = file_get_contents(resource_path('js/v5/lib/use-team-channel.ts'));
|
||||
$event = new V5ClusterUpdated(1, 1);
|
||||
|
||||
expect($event)->toBeInstanceOf(ShouldBroadcast::class)
|
||||
->not->toBeInstanceOf(ShouldBroadcastNow::class)
|
||||
->and($event->broadcastAs())->toBe('v5.cluster.updated')
|
||||
->and($clustersPage)->toContain("useTeamChannel(currentTeam?.id ?? null, '.v5.cluster.updated'")
|
||||
->and($teamChannelSource)->toContain('Waiting for window.Echo before subscribing to ${eventName} updates');
|
||||
});
|
||||
|
||||
it('serves a v5 realtime test page for manual websocket diagnostics', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$page = file_get_contents(resource_path('js/v5/Pages/RealtimeTest.tsx'));
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5/realtime-test')
|
||||
->assertSuccessful()
|
||||
->assertSee('RealtimeTest', false)
|
||||
->assertSee((string) $team->id, false);
|
||||
|
||||
expect($page)
|
||||
->toContain('useTeamChannel(')
|
||||
->toContain("'.v5.realtime.test',")
|
||||
->toContain('{ onDebug: addLog, onError: addLog }')
|
||||
->toContain('Broadcast event');
|
||||
|
||||
expect(file_get_contents(resource_path('js/v5/lib/use-team-channel.ts')))
|
||||
->toContain('Subscribing to private-');
|
||||
});
|
||||
|
||||
it('defines the v5 dashboard page as a shadcn styled canvas shell', function () {
|
||||
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$canvasApi = file_get_contents(resource_path('js/v5/lib/canvas-api.ts'));
|
||||
$toolbar = file_get_contents(resource_path('js/v5/components/canvas/canvas-toolbar.tsx'));
|
||||
$applicationCard = file_get_contents(resource_path('js/v5/components/canvas/application-card.tsx'));
|
||||
$caddyIngressCard = file_get_contents(resource_path('js/v5/components/canvas/caddy-ingress-card.tsx'));
|
||||
$inspectorSheet = file_get_contents(resource_path('js/v5/components/canvas/application-inspector-sheet.tsx'));
|
||||
$ingressDialog = file_get_contents(resource_path('js/v5/components/canvas/ingress-dialog.tsx'));
|
||||
$app = file_get_contents(resource_path('js/v5/app.tsx'));
|
||||
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
|
||||
|
||||
expect(file_exists($navbarPath))->toBeTrue();
|
||||
|
||||
$navbar = file_get_contents($navbarPath);
|
||||
$sheetPath = resource_path('js/v5/components/ui/sheet.tsx');
|
||||
|
||||
expect(file_exists($sheetPath))->toBeTrue();
|
||||
|
||||
expect($app)
|
||||
->toContain('progress: {')
|
||||
->toContain('delay: 10')
|
||||
->toContain("color: '#fcd452'")
|
||||
->toContain('showSpinner: false')
|
||||
->not->toContain('TopNavigationLoadingIndicator')
|
||||
->not->toContain('withApp:');
|
||||
|
||||
expect($dashboardPage)
|
||||
->toContain('Dashboard')
|
||||
->toContain("import { AppNavbar } from '@/components/app-navbar';")
|
||||
->not->toContain('function csrfToken()')
|
||||
->toContain("import { canvasRequest } from '@/lib/canvas-api';")
|
||||
->toContain("import { ApplicationInspectorSheet } from '@/components/canvas/application-inspector-sheet';")
|
||||
->toContain("import { IngressDialog } from '@/components/canvas/ingress-dialog';")
|
||||
->not->toContain("fetch('/v5/clusters'")
|
||||
->toContain('<AppNavbar')
|
||||
->toContain('bg-background text-foreground')
|
||||
->toContain('h-dvh overflow-hidden bg-background text-foreground')
|
||||
->toContain('relative h-full min-h-0 overflow-hidden pt-16')
|
||||
->not->toContain('Add nginx')
|
||||
->toContain('selectedNginxServerId')
|
||||
->toContain('nginxImage')
|
||||
->toContain('docker.io/library/nginx:alpine')
|
||||
->toContain('server_uuid: selectedNginxServerId || null')
|
||||
->toContain('image: nginxImage.trim() || DEFAULT_NGINX_IMAGE')
|
||||
->toContain('openApplicationInspector')
|
||||
->toContain('selectedInspectorApplication')
|
||||
->toContain('application={selectedInspectorApplication}')
|
||||
->toContain("method: 'DELETE'")
|
||||
->toContain('removeApplication')
|
||||
->toContain('useEffect(() => {')
|
||||
->toContain('setApplications(settledResources.applications);')
|
||||
->toContain('centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);')
|
||||
->toContain('persistCaddyIngressPosition')
|
||||
->toContain('startIngressDrag')
|
||||
->toContain('canvasRequest(`/v5/caddy-ingresses/${ingress.id}/position`')
|
||||
->toContain("canvasRequest('/v5/applications/nginx'")
|
||||
->toContain('nginxServers = []')
|
||||
->toContain('canvasRequest(`/v5/applications/${application.id}/position`')
|
||||
->not->toContain('<header')
|
||||
->not->toContain('h-[calc(100dvh-4rem)]')
|
||||
->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground')
|
||||
->toContain('No applications on this canvas yet.')
|
||||
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
|
||||
->not->toContain("fetch('/v5/selection'");
|
||||
|
||||
expect($canvasApi)
|
||||
->not->toContain('function csrfToken()')
|
||||
->toContain("import { csrfToken } from '@/lib/csrf';");
|
||||
|
||||
expect($toolbar)
|
||||
->toContain('Deploy')
|
||||
->toContain('Select nginx server')
|
||||
->toContain('Center');
|
||||
|
||||
expect($applicationCard)
|
||||
->toContain('Delete')
|
||||
->toContain('onDoubleClick={(event) => onOpenInspector(event, application)}');
|
||||
|
||||
expect($caddyIngressCard)->toContain('Caddy ingress');
|
||||
|
||||
expect($inspectorSheet)
|
||||
->toContain("import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';")
|
||||
->toContain("import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';")
|
||||
->toContain('App configuration')
|
||||
->toContain('open={application !== null}')
|
||||
->toContain('<SheetContent side="right" className="w-full overflow-hidden bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]"')
|
||||
->toContain('showCloseButton blurOverlay={false}')
|
||||
->toContain('<SheetHeader className="p-6 pb-4">')
|
||||
->toContain('<div className="flex flex-1 flex-col gap-6 px-6 pb-6">')
|
||||
->toContain('<Tabs defaultValue="overview"')
|
||||
->toContain('<TabsList className="w-full justify-start" variant="line">')
|
||||
->toContain('<TabsTrigger value="overview">Overview</TabsTrigger>')
|
||||
->toContain('<TabsTrigger value="networking">Networking</TabsTrigger>')
|
||||
->toContain('<TabsTrigger value="advanced">Advanced</TabsTrigger>')
|
||||
->toContain('Double-click an application card to open configuration.');
|
||||
|
||||
expect($ingressDialog)->toContain("import { Button } from '@/components/ui/button';");
|
||||
|
||||
expect(file_get_contents($sheetPath))
|
||||
->toContain('blurOverlay = true')
|
||||
->toContain('<SheetOverlay blur={blurOverlay} />');
|
||||
|
||||
expect($navbar)
|
||||
->toContain("import { Link, router, usePage } from '@inertiajs/react';")
|
||||
->toContain("import { cn } from '@/lib/utils';")
|
||||
->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';")
|
||||
->toContain("import { csrfToken } from '@/lib/csrf';")
|
||||
->not->toContain('function csrfToken()')
|
||||
->toContain('export function AppNavbar')
|
||||
->toContain('/coolify-logo.svg')
|
||||
->toContain('<Link')
|
||||
->toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"')
|
||||
->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"')
|
||||
->toContain('hover:bg-muted')
|
||||
->toContain('text-muted-foreground')
|
||||
->toContain('SelectGroup')
|
||||
->toContain('variant="ghost"')
|
||||
->toContain('position="popper"')
|
||||
->toContain('sideOffset={4}')
|
||||
->toContain('Select a project')
|
||||
->toContain('Select an environment')
|
||||
->toContain('const { url } = usePage();')
|
||||
->toContain("const isClustersPage = url.startsWith('/v5/clusters');")
|
||||
->toContain('href="/v5"')
|
||||
->toContain('href="/v5/clusters"')
|
||||
->toContain('Clusters')
|
||||
->toContain('className="relative flex h-16 items-center gap-3 px-4 sm:px-6"')
|
||||
->toContain('className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2"')
|
||||
->toContain('className="max-w-[38vw] md:max-w-[10rem]"')
|
||||
->toContain('className="max-w-[30vw] md:max-w-[10rem]"')
|
||||
->toContain('aria-label="Open mobile menu"')
|
||||
->toContain('<Sheet>')
|
||||
->toContain('<SheetTrigger')
|
||||
->toContain('<SheetContent side="right" className="w-72 max-w-[85vw] bg-background"')
|
||||
->toContain('<SheetHeader>')
|
||||
->toContain('<SheetTitle>Coolify</SheetTitle>')
|
||||
->toContain('<SheetDescription className="sr-only">')
|
||||
->toContain('<SheetClose')
|
||||
->toContain('Move between Coolify v5 pages.')
|
||||
->not->toContain('<SheetTitle className="sr-only">Navigation</SheetTitle>')
|
||||
->toContain('className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"')
|
||||
->toContain('className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"')
|
||||
->toContain('Dashboard')
|
||||
->not->toContain('Home')
|
||||
->toContain("fetch('/v5/selection'")
|
||||
->toContain('router.reload({')
|
||||
->toContain("'applications',")
|
||||
->toContain("'caddyIngresses',")
|
||||
->toContain("'resourceConnections',")
|
||||
->toContain("'nginxServers',")
|
||||
->toContain("'selectedProjectUuid',")
|
||||
->toContain("'selectedEnvironmentUuid',")
|
||||
->toContain('void persistSelection(nextProjectUuid, nextEnvironmentUuid).then((persisted) => {')
|
||||
->toContain('void persistSelection(projectUuid, nextEnvironmentUuid).then((persisted) => {')
|
||||
->toContain('refreshCurrentPageSelection();')
|
||||
->toContain("'X-CSRF-TOKEN': csrfToken()")
|
||||
->not->toContain('isMobileMenuOpen')
|
||||
->not->toContain('setIsMobileMenuOpen')
|
||||
->not->toContain('isProjectEnvironmentMenuOpen')
|
||||
->not->toContain('setIsProjectEnvironmentMenuOpen')
|
||||
->not->toContain('Open project and environment selector')
|
||||
->not->toContain('Close project and environment selector')
|
||||
->not->toContain('DropdownMenu')
|
||||
->not->toContain('fixed inset-0 bg-black/80')
|
||||
->not->toContain('fixed inset-y-0 right-0')
|
||||
->not->toContain('<a')
|
||||
->not->toContain("import { Button } from '@/components/ui/button';")
|
||||
->not->toContain('<Button')
|
||||
->not->toContain("import { Separator } from '@/components/ui/separator';")
|
||||
->not->toContain('<Separator')
|
||||
->not->toContain('min-h-[calc(100vh-4rem)]')
|
||||
->not->toContain('py-10')
|
||||
->not->toContain('bg-background/95')
|
||||
->not->toContain('backdrop-blur')
|
||||
->not->toContain('supports-[backdrop-filter]')
|
||||
->not->toContain('border-coolgray')
|
||||
->not->toContain('className="w-[12rem]"')
|
||||
->not->toContain('<h1>Coolify v5</h1>')
|
||||
->not->toContain('<h2 id="clusters-heading">Clusters</h2>');
|
||||
|
||||
expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('tracks v5 server actions with reusable per-id loading state', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
$pendingIdsHook = file_get_contents(resource_path('js/v5/lib/use-pending-ids.ts'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain("import { usePendingIds } from '@/lib/use-pending-ids';")
|
||||
->toContain('const checkingServers = usePendingIds<string>()')
|
||||
->toContain('const isCheckingServer = checkingServers.has(server.id)')
|
||||
->toContain('checkingServers.start(server.id)')
|
||||
->toContain('checkingServers.finish(server.id)')
|
||||
->not->toContain('const [checkingServerId, setCheckingServerId] = useState<string | null>(null)')
|
||||
->not->toContain('setCheckingServerId(server.id)')
|
||||
->not->toContain('setCheckingServerId(null)');
|
||||
|
||||
expect($pendingIdsHook)
|
||||
->toContain('export function usePendingIds')
|
||||
->toContain('const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set())')
|
||||
->toContain('start')
|
||||
->toContain('finish')
|
||||
->toContain('has')
|
||||
->toContain('hasAny');
|
||||
});
|
||||
|
||||
it('defines the v5 cluster management page and create cluster form', function () {
|
||||
$clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx');
|
||||
$clustersPage = file_get_contents($clustersPagePath);
|
||||
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
|
||||
$dialogComponent = file_get_contents(resource_path('js/v5/components/ui/dialog.tsx'));
|
||||
$types = file_get_contents(resource_path('js/v5/types.ts'));
|
||||
|
||||
expect(file_exists($clustersPagePath))->toBeTrue();
|
||||
|
||||
preg_match('/<Button\s+type="button"\s+variant="coolify"\s+aria-label="Add server to cluster"/m', $clustersPage, $addServerButtonMatches);
|
||||
|
||||
expect($addServerButtonMatches)->not->toBeEmpty();
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain("import { Button } from '@/components/ui/button';")
|
||||
->toContain("} from '@/components/ui/dialog';")
|
||||
->toContain("} from '@/components/ui/dropdown-menu';")
|
||||
->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
|
||||
->toContain("import { apiRequest } from '@/lib/api';")
|
||||
->toContain("apiRequest('/v5/clusters'")
|
||||
->toContain('aria-label="Select a cluster"')
|
||||
->toContain('Cluster state')
|
||||
->not->toContain('Last run')
|
||||
->not->toContain('selectedCluster.lastCliStatus')
|
||||
->not->toContain('selectedCluster.lastCliSummary')
|
||||
->toContain('Generated mesh values are saved after bootstrap or extend runs.')
|
||||
->not->toContain('CLI state')
|
||||
->not->toContain('CLI-generated mesh values')
|
||||
->toContain('Select a cluster')
|
||||
->toContain('setSelectedClusterId(value)')
|
||||
->toContain('aria-label="Create cluster"')
|
||||
->toContain('setIsCreateDialogOpen(true)')
|
||||
->toContain('Add cluster')
|
||||
->toContain('variant="coolify"')
|
||||
->not->toContain('border-warning bg-warning/10 text-foreground')
|
||||
->not->toContain('border-primary bg-primary/10 text-foreground')
|
||||
->toContain('<Dialog')
|
||||
->toContain('<DialogTitle>Create cluster</DialogTitle>')
|
||||
->toContain('<DialogDescription>')
|
||||
->toContain('<DialogFooter>')
|
||||
->not->toContain('<DialogClose')
|
||||
->toContain('Cancel')
|
||||
->toContain('<DialogContent className="max-w-md" showCloseButton={false}>')
|
||||
->toContain('<DialogTitle>Confirm deletion</DialogTitle>')
|
||||
->toContain('variant="delete"')
|
||||
->toContain('aria-label="Add server to cluster"')
|
||||
->toContain('setIsAddServerDialogOpen(true)')
|
||||
->toContain('Add server')
|
||||
->toContain('className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"')
|
||||
->toContain('<DialogTitle>Add server</DialogTitle>')
|
||||
->toContain('DialogContent className="max-w-3xl"')
|
||||
->toContain('DialogContent className="max-w-2xl"')
|
||||
->not->toContain('DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto"')
|
||||
->not->toContain('DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto"')
|
||||
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`')
|
||||
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}`')
|
||||
->toContain('hasBootstrapInProgress')
|
||||
->toContain('Bootstrap')
|
||||
->toContain('lastBootstrapOutput')
|
||||
->toContain('Unable to queue bootstrap for this server.')
|
||||
->toContain('border-destructive/30')
|
||||
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers`')
|
||||
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`')
|
||||
->toContain('apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`')
|
||||
->toContain('apiRequest(`/v5/clusters/${cluster.id}/servers/${server.id}`')
|
||||
->toContain("method: 'PATCH'")
|
||||
->toContain("method: 'DELETE'")
|
||||
->toContain('<DropdownMenu>')
|
||||
->toContain('<DropdownMenuTrigger')
|
||||
->toContain("import { DotsThreeIcon } from '@phosphor-icons/react';")
|
||||
->toContain('variant="ghost"')
|
||||
->toContain('size="icon-sm"')
|
||||
->toContain('aria-label="Server actions"')
|
||||
->toContain('<DotsThreeIcon data-icon="inline-start" weight="bold" />')
|
||||
->not->toContain('>Server actions</DropdownMenuTrigger>')
|
||||
->toContain('<DropdownMenuGroup>')
|
||||
->toContain('<DropdownMenuSeparator />')
|
||||
->toContain('Check connection')
|
||||
->not->toContain('Check SSH')
|
||||
->toContain('Not initialized')
|
||||
->toContain('role="group"')
|
||||
->toContain('aria-label="Server initialization"')
|
||||
->toContain('rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10')
|
||||
->toContain('variant="coolify"')
|
||||
->toContain('className="rounded-r-md"')
|
||||
->toContain('onClick={() => void bootstrapServer(server)}')
|
||||
->not->toContain('<DropdownMenuItem
|
||||
disabled={isBootstrappingServer}')
|
||||
->not->toContain('Bootstrap: {serverStatusLabel(server.status)}')
|
||||
->not->toContain('Last bootstrap')
|
||||
->not->toContain('{formatDate(server.lastBootstrappedAt)}')
|
||||
->toContain("import { CanvasNotice } from '@/components/canvas/canvas-notice';")
|
||||
->toContain('const [serverConnectionNotice, setServerConnectionNotice] = useState<ServerConnectionNotice | null>(null)')
|
||||
->toContain('setServerConnectionNotice({')
|
||||
->toContain('<CanvasNotice')
|
||||
->toContain('variant={serverConnectionNotice.variant}')
|
||||
->toContain('onDismiss={() => setServerConnectionNotice(null)}')
|
||||
->not->toContain('aria-label="Dismiss server connection notification"')
|
||||
->not->toContain('Latest SSH check: {latestSshCheck.status}')
|
||||
->toContain('const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? []')
|
||||
->toContain('const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []')
|
||||
->toContain('Not initialized servers')
|
||||
->toContain('{notInitializedServers.map(renderServerCard)}')
|
||||
->toContain('{initializedServers.map(renderServerCard)}')
|
||||
->toContain('{!isServerInitialized ? (')
|
||||
->toContain('const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false)')
|
||||
->toContain('const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState<string | null>(null)')
|
||||
->toContain('openBootstrapLogs(server)')
|
||||
->toContain('View install logs')
|
||||
->toContain('<DialogTitle>Install logs</DialogTitle>')
|
||||
->toContain('const parsedBootstrapLogs = parseBootstrapOutput(bootstrapLogsServer?.lastBootstrapOutput ?? null)')
|
||||
->toContain('parsedBootstrapLogs.summary.length > 0')
|
||||
->toContain('parsedBootstrapLogs.visibleOutput')
|
||||
->toContain('visibleOutput: hideRawBootstrapPlan(remainingOutput)')
|
||||
->toContain("line.trim() === 'Plan:'")
|
||||
->toContain('Action results')
|
||||
->toContain('max-h-[70dvh]')
|
||||
->toContain('{canShowBootstrapLogs ? (')
|
||||
->not->toContain('Show install logs')
|
||||
->not->toContain('{!isServerInitialized && isBootstrapLogVisible ? (')
|
||||
->not->toContain('setVisibleBootstrapLogs')
|
||||
->toContain('Delete server')
|
||||
->toContain('This removes it from this cluster inventory so you can add it again later.')
|
||||
->not->toContain('<Button\n type="button"\n variant="outline"\n size="sm"\n disabled={isCheckingServer}')
|
||||
->toContain('<DialogTitle>Edit server</DialogTitle>')
|
||||
->toContain('Edit server')
|
||||
->toContain('Save server')
|
||||
->toContain('editServerBuilderCapacity')
|
||||
->toContain('editServerBuilderCpuQuota')
|
||||
->toContain('Networking and bootstrap settings stay locked after creation.')
|
||||
->toContain('Bootstrap SSH user')
|
||||
->toContain('Bootstrap SSH port')
|
||||
->not->toContain('{server.sshUser}@{server.host}:{server.sshPort}')
|
||||
->toContain('showAdvancedServerConfiguration')
|
||||
->toContain('Node address override')
|
||||
->toContain('Defaults to server IP')
|
||||
->not->toContain('CLI node address')
|
||||
->toContain('wireguardListenPortOverride')
|
||||
->toContain('wireguardEndpointOverride')
|
||||
->toContain('privateKeys')
|
||||
->toContain('selectedPrivateKeyId')
|
||||
->toContain('Private key')
|
||||
->toContain('Select a private key')
|
||||
->toContain('appearance-none')
|
||||
->toContain('backgroundImage: `url("data:image/svg+xml')
|
||||
->not->toContain('No private key')
|
||||
->toContain('Create cluster')
|
||||
->toContain('Cluster details')
|
||||
->toContain('Servers in this cluster')
|
||||
->toContain('selectedCluster')
|
||||
->toContain('Server IP')
|
||||
->toContain('{server.host}')
|
||||
->not->toContain('<dt className="text-muted-foreground">CLI node</dt>')
|
||||
->not->toContain('CLI node')
|
||||
->not->toContain('{server.nodeAddress ?? server.host}')
|
||||
->toContain('builderCapacity')
|
||||
->toContain('{server.builderEnabled ? (')
|
||||
->toContain('<dt className="text-muted-foreground">Builder CPU quota</dt>')
|
||||
->toContain('server.builderCpuQuota')
|
||||
->toContain(') : null}')
|
||||
->toContain('privateKeyName')
|
||||
->toContain('lastBootstrappedAt')
|
||||
->toContain('This removes it from this cluster inventory so you can add it again later.')
|
||||
->toContain('deleteCluster')
|
||||
->toContain('apiRequest(`/v5/clusters/${cluster.id}`')
|
||||
->toContain("method: 'DELETE'")
|
||||
->toContain('Delete cluster')
|
||||
->toContain('selectedCluster.serversCount === 0')
|
||||
->not->toContain("selectedCluster.serversCount === 1 ? 'server' : 'servers'")
|
||||
->toContain('Only empty clusters can be deleted.')
|
||||
->toContain('min-h-dvh overflow-y-auto bg-background text-foreground lg:h-dvh lg:overflow-hidden')
|
||||
->toContain('flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6')
|
||||
->toContain('flex w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6')
|
||||
->toContain('rounded-lg border border-border bg-card p-4')
|
||||
->toContain('flex items-start justify-between gap-3')
|
||||
->toContain('min-w-0 flex-1')
|
||||
->toContain('flex shrink-0 items-center justify-end gap-2 sm:flex-wrap')
|
||||
->toContain('aria-label="Select a cluster"')
|
||||
->toContain('setSelectedClusterId(value)')
|
||||
->not->toContain('flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0')
|
||||
->toContain('overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto')
|
||||
->not->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end')
|
||||
->toContain('mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2')
|
||||
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]')
|
||||
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]')
|
||||
->not->toContain('New cluster')
|
||||
->not->toContain('<aside className="rounded-lg border border-border bg-card p-5">')
|
||||
->not->toContain('This is where the magic happens.');
|
||||
|
||||
expect(substr_count($clustersPage, 'variant="coolify"'))->toBe(6)
|
||||
->and($buttonComponent)
|
||||
->toContain('coolify:')
|
||||
->toContain('bg-coollabs-50')
|
||||
->toContain('hover:bg-coollabs')
|
||||
->toContain('delete:');
|
||||
|
||||
expect($dialogComponent)
|
||||
->toContain('@base-ui/react/dialog')
|
||||
->toContain('z-50')
|
||||
->toContain('max-h-[calc(100dvh-2rem)]')
|
||||
->toContain('overflow-y-auto')
|
||||
->toContain('top-1/2')
|
||||
->toContain('-translate-y-1/2')
|
||||
->not->toContain('top-4 bottom-4')
|
||||
->not->toContain('top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2')
|
||||
->toContain('showCloseButton = true')
|
||||
->toContain('aria-label="Close dialog"')
|
||||
->toContain('<XIcon />')
|
||||
->toContain('DialogTitle')
|
||||
->toContain('DialogDescription')
|
||||
->toContain("'mt-6 flex justify-end gap-2'");
|
||||
|
||||
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
|
||||
|
||||
expect($v5Css)
|
||||
->toContain('--color-warning: var(--warning);')
|
||||
->toContain('--warning: #fcd452;')
|
||||
->not->toContain('--ring: oklch(0.705 0.015 286.067);')
|
||||
->not->toContain('--ring: oklch(0.552 0.016 285.938);');
|
||||
|
||||
expect(substr_count($v5Css, '--ring: var(--warning);'))->toBe(2);
|
||||
|
||||
expect($types)
|
||||
->not->toContain('sshUser: string;')
|
||||
->not->toContain('sshPort: number;')
|
||||
->toContain('builderEnabled: boolean;')
|
||||
->toContain('builderCapacity: number;')
|
||||
->toContain('builderCpuQuota: string;')
|
||||
->toContain('privateKeyName: string | null;')
|
||||
->toContain('lastBootstrappedAt: string | null;')
|
||||
->toContain('lastBootstrapStatus: string | null;')
|
||||
->toContain('lastStatusOutput: string | null;');
|
||||
});
|
||||
|
||||
it('uses the standard button size for the v5 delete cluster action', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain("variant=\"delete\"\n size=\"default\"\n onClick={openDeleteClusterDialog}")
|
||||
->not->toContain("variant=\"delete\"\n size=\"sm\"\n onClick={openDeleteClusterDialog}");
|
||||
});
|
||||
|
||||
it('shows the v5 server delete validation error in the confirmation dialog', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain('const payload = (await response?.json().catch(() => null)) as { message?: string } | null;')
|
||||
->toContain('response?.status === 422')
|
||||
->toContain("'Delete or move applications from this server before deleting it.'")
|
||||
->toContain("'Unable to delete this server. Please try again.'")
|
||||
->toContain('role="alert"')
|
||||
->toContain('border-destructive/30 bg-destructive/10')
|
||||
->toContain('setDeleteClusterError(null);');
|
||||
});
|
||||
|
||||
it('defines a ghost variant for compact v5 select triggers', function () {
|
||||
$select = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
|
||||
|
||||
expect($select)
|
||||
->toContain('@base-ui/react/select')
|
||||
->not->toContain('radix-ui')
|
||||
->toContain("variant = 'default'")
|
||||
->toContain("variant === 'default'")
|
||||
->toContain("variant === 'ghost'")
|
||||
->toContain('border-transparent')
|
||||
->toContain('h-auto')
|
||||
->toContain('text-sm')
|
||||
->toContain("position === 'popper' ? false : true");
|
||||
});
|
||||
|
||||
it('provides reusable v5 form field primitives with reserved validation error space', function () {
|
||||
$fieldPath = resource_path('js/v5/components/ui/field.tsx');
|
||||
$inputPath = resource_path('js/v5/components/ui/input.tsx');
|
||||
$textareaPath = resource_path('js/v5/components/ui/textarea.tsx');
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect(file_exists($fieldPath))->toBeTrue()
|
||||
->and(file_exists($inputPath))->toBeTrue()
|
||||
->and(file_exists($textareaPath))->toBeTrue();
|
||||
|
||||
$field = file_get_contents($fieldPath);
|
||||
$input = file_get_contents($inputPath);
|
||||
$textarea = file_get_contents($textareaPath);
|
||||
|
||||
expect($field)
|
||||
->toContain('function Field(')
|
||||
->toContain('function FieldLabel(')
|
||||
->toContain('function FieldError(')
|
||||
->toContain('min-h-4')
|
||||
->toContain('aria-live="polite"')
|
||||
->toContain('message ? undefined : true')
|
||||
->toContain('export { Field, FieldError, FieldLabel }');
|
||||
|
||||
expect($input)
|
||||
->toContain('function Input(')
|
||||
->toContain('aria-invalid:border-destructive')
|
||||
->toContain('export { Input };');
|
||||
|
||||
expect($textarea)
|
||||
->toContain('function Textarea(')
|
||||
->toContain('aria-invalid:border-destructive')
|
||||
->toContain('export { Textarea };');
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain("import { Field, FieldError, FieldLabel } from '@/components/ui/field';")
|
||||
->toContain("import { Input } from '@/components/ui/input';")
|
||||
->toContain("import { Textarea } from '@/components/ui/textarea';")
|
||||
->toContain('<FieldError message={errors.name?.[0]} />')
|
||||
->toContain('<FieldError message={serverErrors.private_key_uuid?.[0]} />')
|
||||
->not->toContain('{errors.name ? <span className="text-xs text-destructive">{errors.name[0]}</span> : null}')
|
||||
->not->toContain('{serverErrors.name ? (');
|
||||
});
|
||||
|
||||
it('uses the requested shadcn preset configuration for v5', function () {
|
||||
$components = json_decode(file_get_contents(base_path('components.json')), true);
|
||||
$css = file_get_contents(resource_path('css/v5/app.css'));
|
||||
|
||||
expect($components['style'])
|
||||
->toBe('base-lyra')
|
||||
->and($components['tsx'])->toBeTrue()
|
||||
->and($components['iconLibrary'])->toBe('phosphor')
|
||||
->and($components['tailwind']['css'])->toBe('resources/css/v5/app.css')
|
||||
->and($components['tailwind']['baseColor'])->toBe('zinc')
|
||||
->and($css)->toContain('@import "@fontsource-variable/geist";')
|
||||
->and($css)->toContain('--foreground: oklch(0.141 0.005 285.823);')
|
||||
->and($css)->toContain('--background: #101010;')
|
||||
->and($css)->toContain('button:not(:disabled)');
|
||||
});
|
||||
|
||||
it('sizes the v5 app root with the dynamic mobile viewport', function () {
|
||||
$css = file_get_contents(resource_path('css/v5/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('min-height: 100dvh;')
|
||||
->not->toContain('min-height: 100vh;');
|
||||
});
|
||||
|
||||
it('does not include coolify version controls on the v5 dashboard page', function () {
|
||||
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
|
||||
expect($dashboardPage)
|
||||
->not->toContain('Check coolify version')
|
||||
->not->toContain('/v5/coolify/version')
|
||||
->not->toContain('Installed version:');
|
||||
});
|
||||
|
||||
it('does not render flux status in the v5 navbar', function () {
|
||||
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
|
||||
|
||||
expect($navbar)
|
||||
->not->toContain('Flux: {flux?.label ??')
|
||||
->not->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
|
||||
->not->toContain('{clusters.length} clusters')
|
||||
->not->toContain('<h2 id="flux-status-heading">Flux status</h2>')
|
||||
->not->toContain('<p>{flux.message}</p>')
|
||||
->not->toContain('Socket: {flux.socket}');
|
||||
});
|
||||
|
||||
it('hardens v5 dashboard fetches and websocket merges against failures', function () {
|
||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||
$canvasApiSource = file_get_contents(resource_path('js/v5/lib/canvas-api.ts'));
|
||||
$statusBadgeSource = file_get_contents(resource_path('js/v5/components/canvas/status-badge.ts'));
|
||||
|
||||
expect($canvasApiSource)->toContain('AbortSignal.timeout(30_000)');
|
||||
|
||||
expect($statusBadgeSource)->toContain("'starting'");
|
||||
|
||||
expect($dashboardSource)->toContain('Could not save the card position.');
|
||||
|
||||
$addNginxSource = substr($dashboardSource, strpos($dashboardSource, 'addNginx'));
|
||||
|
||||
expect(strpos($addNginxSource, '!response.ok'))->not->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('reuses existing projects instead of creating v5 projects', function () {
|
||||
expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
|
||||
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates v5 cluster tables and lets each server belong to one cluster', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_clusters', [
|
||||
'id',
|
||||
'team_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'description',
|
||||
'wireguard_interface',
|
||||
'wireguard_management_pool',
|
||||
'wireguard_listen_port',
|
||||
'container_network_pool',
|
||||
'container_network_prefix',
|
||||
'namespaces',
|
||||
'default_deny_containers',
|
||||
'coold_version',
|
||||
'corrosion_version',
|
||||
'corrosion_gossip_port',
|
||||
'corrosion_api_port',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'builder_memory_max',
|
||||
'builder_timeout_secs',
|
||||
'last_cli_action',
|
||||
'last_cli_status',
|
||||
'last_cli_summary',
|
||||
'last_cli_ran_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue()
|
||||
->and(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates v5 server tables in the shared database', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$migration->up();
|
||||
|
||||
expect(Schema::hasTable('v5_servers'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_servers', [
|
||||
'id',
|
||||
'team_id',
|
||||
'cluster_id',
|
||||
'created_by_user_id',
|
||||
'private_key_id',
|
||||
'name',
|
||||
'host',
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'status',
|
||||
'capabilities',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'node_address',
|
||||
'wireguard_listen_port_override',
|
||||
'wireguard_endpoint_override',
|
||||
'uuid',
|
||||
'wireguard_management_ip',
|
||||
'wireguard_public_key',
|
||||
'container_subnets',
|
||||
'last_bootstrapped_at',
|
||||
'last_bootstrap_action',
|
||||
'last_bootstrap_status',
|
||||
'last_bootstrap_output',
|
||||
'last_bootstrap_ran_at',
|
||||
'last_status_check',
|
||||
'last_status_output',
|
||||
'last_status_checked_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates v5 server canvas columns for movable caddy ingress nodes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
expect(Schema::hasColumns('v5_servers', [
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates v5 server ingress columns', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
expect(Schema::hasColumns('v5_servers', [
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates v5 application tables for dashboard canvas nodes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
|
||||
$applicationMigration->up();
|
||||
|
||||
expect(Schema::hasTable('v5_applications'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_applications', [
|
||||
'id',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'image',
|
||||
'container_name',
|
||||
'status',
|
||||
'status_message',
|
||||
'runtime_container_id',
|
||||
'mesh_namespace',
|
||||
'ingress_enabled',
|
||||
'internal_port',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates v5 application domain tables for zero or more inbound routes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
|
||||
$applicationMigration->up();
|
||||
|
||||
expect(Schema::hasTable('v5_application_domains'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_application_domains', [
|
||||
'id',
|
||||
'application_id',
|
||||
'domain',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_applications', [
|
||||
'ingress_enabled',
|
||||
'internal_port',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates generic v5 resource connection tables', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
|
||||
$applicationMigration->up();
|
||||
|
||||
$connectionMigration = include database_path('migrations/2026_06_19_142000_v5_create_resource_connections_table.php');
|
||||
$connectionMigration->up();
|
||||
|
||||
expect(Schema::hasTable('v5_resource_connections'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_resource_connections', [
|
||||
'id',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'resource_one_type',
|
||||
'resource_one_id',
|
||||
'resource_two_type',
|
||||
'resource_two_id',
|
||||
'resource_pair_key',
|
||||
'created_by_user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue()
|
||||
->and(Schema::hasTable('v5_resource_connection_rules'))->toBeTrue()
|
||||
->and(Schema::hasColumns('v5_resource_connection_rules', [
|
||||
'id',
|
||||
'connection_id',
|
||||
'source_resource_type',
|
||||
'source_resource_id',
|
||||
'target_resource_type',
|
||||
'target_resource_id',
|
||||
'protocol',
|
||||
'port',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps v5 server fields in the initial migration', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
|
||||
$clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php');
|
||||
$clusterMigration->up();
|
||||
|
||||
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
|
||||
$serverMigration->up();
|
||||
|
||||
expect(Schema::hasColumns('v5_servers', [
|
||||
'uuid',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'builder_cpu_quota',
|
||||
'node_address',
|
||||
'wireguard_management_ip',
|
||||
'container_subnets',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
'last_bootstrap_output',
|
||||
'last_status_output',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('includes v5 tables in the dev testing schema', function () {
|
||||
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
|
||||
|
||||
expect($schema)->toContain('"team_id" INTEGER NOT NULL')
|
||||
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
|
||||
->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
|
||||
->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_container_statuses"')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_applications"')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_application_domains"')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connections"')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules"')
|
||||
->and($schema)->toContain('"domain" TEXT NOT NULL')
|
||||
->and($schema)->toContain('"ingress_enabled" INTEGER DEFAULT false NOT NULL')
|
||||
->and($schema)->toContain('"internal_port" INTEGER')
|
||||
->and($schema)->toContain('"cluster_id" INTEGER')
|
||||
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"')
|
||||
->and($schema)->toContain('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL')
|
||||
->and($schema)->toContain('"wireguard_management_pool" TEXT DEFAULT \'100.64.0.0/16\' NOT NULL')
|
||||
->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL')
|
||||
->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'')
|
||||
->and($schema)->toContain('"private_key_id" INTEGER')
|
||||
->and($schema)->toContain('"ingress_type" TEXT')
|
||||
->and($schema)->toContain('"ingress_status" TEXT')
|
||||
->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL')
|
||||
->and($schema)->toContain('"uuid" TEXT')
|
||||
->and($schema)->toContain('"wireguard_management_ip" TEXT')
|
||||
->and($schema)->toContain('"container_subnets" JSON')
|
||||
->and($schema)->toContain('"canvas_x" INTEGER')
|
||||
->and($schema)->toContain('"canvas_y" INTEGER')
|
||||
->and($schema)->toContain('"last_bootstrap_output" TEXT')
|
||||
->and($schema)->toContain('"last_status_output" TEXT')
|
||||
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
|
||||
->and($schema)->toContain('2026_06_19_140000_v5_create_applications_table')
|
||||
->and($schema)->toContain('2026_06_19_142000_v5_create_resource_connections_table')
|
||||
->and($schema)->toContain('2026_06_19_182231_create_container_statuses_table')
|
||||
->and($schema)->not->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_19_173933_add_ingress_status_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_table')
|
||||
->and($schema)->not->toContain('2026_06_19_150000_add_mesh_namespace_to_v5_applications_table')
|
||||
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
|
||||
->and($schema)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
|
||||
->and($schema)->not->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_19_064539_add_bootstrap_log_fields_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_19_090217_add_uuid_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
|
||||
->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable')
|
||||
->and($schema)->not->toContain('v5_hosts');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Kernel;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Http\Middleware\V5\EnsureCurrentTeam;
|
||||
use App\Http\Middleware\V5\HandleInertiaRequests;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('registers the v5 dashboard route', function () {
|
||||
expect(Route::has('v5.dashboard'))->toBeTrue()
|
||||
->and(Route::has('v5.home'))->toBeFalse()
|
||||
->and(Route::has('v5.selection.update'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.index'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.show'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.store'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.destroy'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.store'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.update'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.check'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.coold-logs'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.corrosion-tables'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue()
|
||||
->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue()
|
||||
->and(Route::has('v5.applications.nginx'))->toBeTrue()
|
||||
->and(Route::has('v5.applications.refresh'))->toBeTrue()
|
||||
->and(Route::has('v5.applications.position'))->toBeTrue()
|
||||
->and(Route::has('v5.applications.ingress'))->toBeTrue()
|
||||
->and(Route::has('v5.caddy-ingresses.position'))->toBeTrue()
|
||||
->and(Route::has('v5.applications.destroy'))->toBeTrue()
|
||||
->and(Route::has('v5.resource-connections.store'))->toBeTrue()
|
||||
->and(Route::has('v5.resource-connections.update'))->toBeTrue()
|
||||
->and(Route::has('v5.resource-connections.destroy'))->toBeTrue()
|
||||
->and(Route::has('v5.realtime-test'))->toBeTrue()
|
||||
->and(Route::has('v5.realtime-test.broadcast'))->toBeTrue()
|
||||
->and(Route::has('v5.coolify.version'))->toBeFalse()
|
||||
->and(Route::has('v5.coolify.bootstrap'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses separated v5 middleware groups', function () {
|
||||
$kernel = app(Kernel::class);
|
||||
$reflection = new ReflectionClass($kernel);
|
||||
$property = $reflection->getProperty('middlewareGroups');
|
||||
$property->setAccessible(true);
|
||||
$groups = $property->getValue($kernel);
|
||||
|
||||
expect($groups)->toHaveKey('v5.web')
|
||||
->and($groups)->toHaveKey('v5.authenticated')
|
||||
->and($groups['v5.web'])->toContain(HandleInertiaRequests::class)
|
||||
->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class)
|
||||
->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class)
|
||||
->and($groups['v5.authenticated'])->toContain('auth')
|
||||
->and($groups['v5.authenticated'])->toContain('verified')
|
||||
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
|
||||
});
|
||||
|
||||
it('redirects guests to the shared login', function () {
|
||||
$this->get('/v5')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('selects a shared team when the session has no current team', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Grace Hopper',
|
||||
'email' => 'grace@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Auto Selected Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'admin']);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSessionHas('currentTeam')
|
||||
->assertDontSee('Auto Selected Team')
|
||||
->assertDontSee('admin');
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\V5TeardownTeamJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\RevokedAgentToken;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->team = Team::factory()->create(['name' => 'Teardown Team']);
|
||||
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createTeardownServer(Team $team, User $owner, PrivateKey $privateKey, array $attributes = []): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $owner->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-'.fake()->unique()->numberBetween(1, 9999),
|
||||
'host' => fake()->unique()->ipv4(),
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function createTeardownApplication(Team $team, User $owner, V5Server $server): V5Application
|
||||
{
|
||||
$project = Project::query()->create(['name' => fake()->unique()->company(), 'team_id' => $team->id]);
|
||||
// Project creation auto-provisions a default environment; reuse it rather
|
||||
// than colliding with the (name, project_id) unique index.
|
||||
$environment = $project->environments()->first()
|
||||
?? Environment::query()->create(['name' => 'production', 'project_id' => $project->id]);
|
||||
|
||||
// The checked-in testing-schema.sql dump predates the v5_applications.uuid
|
||||
// column, so skip the V5Model uuid-generating event (matching how the other
|
||||
// V5 suites hand-roll their schema) to insert against the dump.
|
||||
return V5Application::withoutEvents(fn () => V5Application::query()->forceCreate([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $owner->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-'.fake()->unique()->numerify('#######'),
|
||||
'status' => 'running',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @param array<int, array<string, mixed>> $applications
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function teardownServerPayload(array $overrides = [], array $applications = []): array
|
||||
{
|
||||
return [
|
||||
'id' => 1,
|
||||
'uuid' => 'teardown-server-uuid',
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'node_address' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'is_ingress' => false,
|
||||
'ingress_type' => null,
|
||||
'status' => 'installed',
|
||||
'last_bootstrapped_at' => now()->toISOString(),
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
'applications' => $applications,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
|
||||
it('dispatches the v5 teardown job for the team servers when the team is deleted', function () {
|
||||
Queue::fake();
|
||||
|
||||
$serverOne = createTeardownServer($this->team, $this->owner, $this->privateKey, [
|
||||
'host' => '203.0.113.10',
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
createTeardownApplication($this->team, $this->owner, $serverOne);
|
||||
$serverTwo = createTeardownServer($this->team, $this->owner, $this->privateKey, [
|
||||
'host' => '203.0.113.11',
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this->team->delete();
|
||||
|
||||
// The team deletion completes (the teardown hook never blocks it). The
|
||||
// per-row cascade is a database-level guarantee from the v5 migrations'
|
||||
// cascadeOnDelete() foreign keys; the in-memory SQLite test dump does not
|
||||
// enforce it, so it is not asserted here.
|
||||
expect(Team::find($this->team->id))->toBeNull();
|
||||
|
||||
Queue::assertPushed(V5TeardownTeamJob::class, function (V5TeardownTeamJob $job): bool {
|
||||
if ($job->teamId !== $this->team->id || count($job->servers) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ingressServer = collect($job->servers)->firstWhere('host', '203.0.113.10');
|
||||
|
||||
return $ingressServer !== null
|
||||
&& $ingressServer['is_ingress'] === true
|
||||
&& str_contains((string) $ingressServer['private_key'], 'BEGIN OPENSSH PRIVATE KEY')
|
||||
&& count($ingressServer['applications']) === 1
|
||||
&& str_starts_with((string) $ingressServer['applications'][0]['container_name'], 'coolify-v5-nginx-');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch teardown when the team owns no v5 servers', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->team->delete();
|
||||
|
||||
Queue::assertNotPushed(V5TeardownTeamJob::class);
|
||||
});
|
||||
|
||||
it('runs the full per-server teardown sequence over ssh and flux', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
|
||||
$mock->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
});
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$payload = teardownServerPayload([
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
], [
|
||||
['id' => 5, 'container_name' => 'coolify-v5-nginx-abc', 'runtime_container_id' => 'runtime-1'],
|
||||
]);
|
||||
|
||||
(new V5TeardownTeamJob($this->team->id, [$payload]))->handle();
|
||||
|
||||
// Application container removal ran over SSH.
|
||||
Process::assertRan(fn ($process) => is_array($process->command)
|
||||
&& ($process->command[0] ?? null) === 'ssh'
|
||||
&& str_contains((string) end($process->command), "rm -f 'coolify-v5-nginx-abc'"));
|
||||
|
||||
// On-host bootstrap identity removal ran over SSH.
|
||||
Process::assertRan(fn ($process) => is_array($process->command)
|
||||
&& ($process->command[0] ?? null) === 'ssh'
|
||||
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
|
||||
});
|
||||
|
||||
it('keeps tearing down other servers when one host is unreachable', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
// The ingress server's Flux stop throws (unreachable host); teardown of the
|
||||
// rest must still proceed and the job must not fail.
|
||||
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
|
||||
$mock->shouldReceive('stopIngress')
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andThrow(new RuntimeException('flux socket unreachable'));
|
||||
});
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$unreachableIngress = teardownServerPayload([
|
||||
'id' => 1,
|
||||
'host' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
]);
|
||||
$healthyServer = teardownServerPayload([
|
||||
'id' => 2,
|
||||
'host' => '203.0.113.11',
|
||||
'wireguard_management_ip' => '100.64.0.11',
|
||||
]);
|
||||
|
||||
(new V5TeardownTeamJob($this->team->id, [$unreachableIngress, $healthyServer]))->handle();
|
||||
|
||||
// The healthy server was still cleaned up despite the first host failing.
|
||||
Process::assertRan(fn ($process) => is_array($process->command)
|
||||
&& in_array('root@203.0.113.11', $process->command, true)
|
||||
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
|
||||
|
||||
// The unreachable ingress server's marker removal was still attempted.
|
||||
Process::assertRan(fn ($process) => is_array($process->command)
|
||||
&& in_array('root@203.0.113.10', $process->command, true)
|
||||
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
|
||||
});
|
||||
|
||||
it('captures each server agent token jti and expiry in the dispatched payload', function () {
|
||||
Queue::fake();
|
||||
|
||||
$server = createTeardownServer($this->team, $this->owner, $this->privateKey, [
|
||||
'host' => '203.0.113.42',
|
||||
'agent_token_jti' => 'jti-captured',
|
||||
'agent_token_expires_at' => now()->addDay(),
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this->team->delete();
|
||||
|
||||
Queue::assertPushed(V5TeardownTeamJob::class, function (V5TeardownTeamJob $job): bool {
|
||||
$payload = collect($job->servers)->firstWhere('host', '203.0.113.42');
|
||||
|
||||
return $payload !== null
|
||||
&& $payload['agent_token_jti'] === 'jti-captured'
|
||||
&& $payload['agent_token_expires_at'] !== null;
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes each server host token during teardown', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$expiresAtOne = now()->addDay();
|
||||
$expiresAtTwo = now()->addDays(2);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock) use ($expiresAtOne, $expiresAtTwo): void {
|
||||
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
|
||||
$mock->shouldReceive('revokeToken')->once()->with('jti-1', $expiresAtOne->timestamp);
|
||||
$mock->shouldReceive('revokeToken')->once()->with('jti-2', $expiresAtTwo->timestamp);
|
||||
});
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
// last_bootstrapped_at null so only the explicit token revocation runs (no
|
||||
// SSH marker removal, which would revoke a second time).
|
||||
$serverOne = teardownServerPayload([
|
||||
'id' => 1,
|
||||
'host' => '203.0.113.10',
|
||||
'last_bootstrapped_at' => null,
|
||||
'agent_token_jti' => 'jti-1',
|
||||
'agent_token_expires_at' => $expiresAtOne->toISOString(),
|
||||
]);
|
||||
$serverTwo = teardownServerPayload([
|
||||
'id' => 2,
|
||||
'host' => '203.0.113.11',
|
||||
'last_bootstrapped_at' => null,
|
||||
'agent_token_jti' => 'jti-2',
|
||||
'agent_token_expires_at' => $expiresAtTwo->toISOString(),
|
||||
]);
|
||||
|
||||
(new V5TeardownTeamJob($this->team->id, [$serverOne, $serverTwo]))->handle();
|
||||
|
||||
expect(RevokedAgentToken::query()->where('jti', 'jti-1')->exists())->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', 'jti-2')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('logs the incomplete-teardown error naming hosts that could not be torn down', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
Log::spy();
|
||||
|
||||
// The ingress host's Flux stop throws (unreachable) so it is reported
|
||||
// incomplete; the healthy host tears down cleanly.
|
||||
$fluxClient = Mockery::mock(FluxClient::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('revokeFirewallRule')->andReturn('Firewall rule revoked.');
|
||||
$mock->shouldReceive('revokeToken')->andReturnNull();
|
||||
$mock->shouldReceive('stopIngress')
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andThrow(new RuntimeException('flux socket unreachable'));
|
||||
});
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$unreachableIngress = teardownServerPayload([
|
||||
'id' => 1,
|
||||
'host' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
]);
|
||||
$healthyServer = teardownServerPayload([
|
||||
'id' => 2,
|
||||
'host' => '203.0.113.11',
|
||||
'wireguard_management_ip' => '100.64.0.11',
|
||||
]);
|
||||
|
||||
(new V5TeardownTeamJob($this->team->id, [$unreachableIngress, $healthyServer]))->handle();
|
||||
|
||||
// The healthy host was still cleaned up despite the ingress host failing.
|
||||
Process::assertRan(fn ($process) => is_array($process->command)
|
||||
&& in_array('root@203.0.113.11', $process->command, true)
|
||||
&& str_contains((string) end($process->command), '/etc/coolify/v5-node.json'));
|
||||
|
||||
// The operator-facing signal names only the unreachable host.
|
||||
Log::shouldHaveReceived('error')->withArgs(
|
||||
fn (string $message, array $context = []) => str_contains($message, 'v5 team teardown incomplete')
|
||||
&& ($context['team_id'] ?? null) === $this->team->id
|
||||
&& collect($context['hosts'] ?? [])->contains(fn ($host) => ($host['host'] ?? null) === '203.0.113.10')
|
||||
&& collect($context['hosts'] ?? [])->doesntContain(fn ($host) => ($host['host'] ?? null) === '203.0.113.11')
|
||||
)->once();
|
||||
});
|
||||
|
||||
it('does not remove the bootstrap marker for servers that were never bootstrapped', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$payload = teardownServerPayload([
|
||||
'last_bootstrapped_at' => null,
|
||||
'is_ingress' => false,
|
||||
]);
|
||||
|
||||
(new V5TeardownTeamJob($this->team->id, [$payload]))->handle();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
@@ -16,6 +16,17 @@ use Tests\TestCase;
|
||||
*/
|
||||
uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Shared Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Helper functions shared across multiple test files.
|
||||
|
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/Support/V5TestHelpers.php';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Hooks
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Shared V5 Test Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Global Pest helper functions shared by the tests/Feature/V5 and
|
||||
| tests/Unit/V5 suites. Loaded from tests/Pest.php. The v5_* table
|
||||
| definitions themselves live in Tests\Support\V5TestSchema so they cannot
|
||||
| drift from the migrations per test file.
|
||||
|
|
||||
*/
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
/**
|
||||
* The shared per-test reset used by the split V5 dashboard feature tests
|
||||
* (previously the DashboardTest beforeEach).
|
||||
*/
|
||||
function resetV5DashboardTestState(): void
|
||||
{
|
||||
Config::set('app.maintenance.store', 'array');
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('flux.bootstrap_host_connection_timeout_seconds', 0);
|
||||
|
||||
V5TestSchema::dropAllTables();
|
||||
Schema::dropIfExists('private_keys');
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
|
||||
function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void
|
||||
{
|
||||
app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) {
|
||||
$mock->shouldReceive('check')
|
||||
->once()
|
||||
->andReturn([
|
||||
'available' => $available,
|
||||
'label' => $available ? 'Running' : 'Unavailable',
|
||||
'message' => $message,
|
||||
'socket' => '/run/coolify/flux.sock',
|
||||
]);
|
||||
}));
|
||||
}
|
||||
|
||||
function fakeAgentTokenIssuer(string $token = 'signed-host-jwt'): void
|
||||
{
|
||||
$issuer = Mockery::mock(AgentTokenIssuer::class);
|
||||
$issuer->shouldReceive('issueForServer')->andReturn($token);
|
||||
app()->instance(AgentTokenIssuer::class, $issuer);
|
||||
}
|
||||
|
||||
function fakeSuccessfulNginxFluxDeployment(string $image = 'docker.io/library/nginx:alpine'): void
|
||||
{
|
||||
$mock = Mockery::mock(FluxClient::class, function (MockInterface $mock) use ($image): void {
|
||||
$mock->shouldReceive('pullImage')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $image)
|
||||
->andReturn('Image pulled.');
|
||||
$mock->shouldReceive('createContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), Mockery::on(fn (array $spec): bool => ($spec['image'] ?? null) === $image
|
||||
&& ($spec['networks'] ?? []) === ['coolify-default-mesh']
|
||||
&& ($spec['dns_search'] ?? []) === ['default.coolify.internal']
|
||||
&& in_array($spec['name'] ?? '', $spec['network_aliases'] ?? [], true)))
|
||||
->andReturn('nginx-container-id');
|
||||
$mock->shouldReceive('startContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'nginx-container-id')
|
||||
->andReturn('Container started.');
|
||||
$mock->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'nginx-container-id')
|
||||
->andReturn(['State' => ['Running' => true]]);
|
||||
});
|
||||
|
||||
app()->instance(FluxClient::class, $mock);
|
||||
}
|
||||
|
||||
function expectCaddyIngressFirewallRule(mixed $fluxClient): void
|
||||
{
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => 'v5-caddy-ingress:80',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
$fluxClient
|
||||
->shouldNotReceive('applyFirewallRule')
|
||||
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
|
||||
}
|
||||
|
||||
function createSharedUserAndTeamTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('private_keys', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->longText('private_key');
|
||||
$table->string('fingerprint')->nullable();
|
||||
$table->boolean('is_git_related')->default(false);
|
||||
$table->foreignId('team_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('projects', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid');
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignId('team_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('environments', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->foreignId('project_id');
|
||||
$table->timestamps();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('uuid');
|
||||
});
|
||||
|
||||
V5TestSchema::createAllTables();
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $command
|
||||
*/
|
||||
function cliFlagValue(array $command, string $flag): ?string
|
||||
{
|
||||
$index = array_search($flag, $command, true);
|
||||
|
||||
if ($index === false || ! isset($command[$index + 1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $command[$index + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Project, 1: Environment}
|
||||
*/
|
||||
function createV5ProjectWithEnvironment(Team $team, string $projectName, string $environmentName): array
|
||||
{
|
||||
$project = Project::withoutEvents(fn () => Project::query()->forceCreate([
|
||||
'uuid' => str($projectName)->slug().'-uuid',
|
||||
'name' => $projectName,
|
||||
'description' => null,
|
||||
'team_id' => $team->id,
|
||||
]));
|
||||
|
||||
$environment = Environment::withoutEvents(fn () => Environment::query()->forceCreate([
|
||||
'uuid' => str($environmentName)->slug().'-uuid',
|
||||
'name' => $environmentName,
|
||||
'description' => null,
|
||||
'project_id' => $project->id,
|
||||
]));
|
||||
|
||||
return [$project, $environment];
|
||||
}
|
||||
|
||||
function createV5PrivateKey(Team $team, string $name): PrivateKey
|
||||
{
|
||||
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
|
||||
'uuid' => str($name)->slug().'-uuid',
|
||||
'name' => $name,
|
||||
'description' => null,
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
'fingerprint' => str($name)->slug()->toString(),
|
||||
'is_git_related' => false,
|
||||
'team_id' => $team->id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Team}
|
||||
*/
|
||||
function createV5UserWithTeam(string $email = 'margaret@example.com'): array
|
||||
{
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Tooling Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
return [$user, $team];
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Single source of truth for the v5_* table definitions used by tests that
|
||||
* build their schema by hand instead of running the full migration set.
|
||||
*
|
||||
* The column definitions must exactly mirror the final state produced by the
|
||||
* database/migrations/*v5* files (including the 2026_07_05/2026_07_06
|
||||
* additions: status_observed_at, coold_version, has_coold/is_ingress booleans
|
||||
* replacing the dropped capabilities json, and NOT NULL server uuids). Update
|
||||
* this class in the same change as any v5 migration.
|
||||
*
|
||||
* Cross-table foreign key constraints and the composite unique indexes on
|
||||
* v5_servers (team_id/host/ssh_port) and v5_clusters (team_id/name) are
|
||||
* intentionally omitted: tests seed sparse rows (team_id => 1 without a teams
|
||||
* row) and rely on application-level validation, matching the previous
|
||||
* hand-rolled schemas. Unique indexes that encode behaviour are kept.
|
||||
*/
|
||||
class V5TestSchema
|
||||
{
|
||||
public static function createAllTables(): void
|
||||
{
|
||||
self::createClustersTable();
|
||||
self::createServersTable();
|
||||
self::createContainerStatusesTable();
|
||||
self::createApplicationsTable();
|
||||
self::createApplicationDomainsTable();
|
||||
self::createResourceConnectionsTable();
|
||||
self::createResourceConnectionRulesTable();
|
||||
self::createRevokedAgentTokensTable();
|
||||
}
|
||||
|
||||
public static function dropAllTables(): void
|
||||
{
|
||||
Schema::dropIfExists('v5_revoked_agent_tokens');
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
}
|
||||
|
||||
public static function createClustersTable(): void
|
||||
{
|
||||
Schema::create('v5_clusters', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('created_by_user_id');
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('wireguard_interface')->default('wg0');
|
||||
$table->string('wireguard_management_pool')->default('100.64.0.0/16');
|
||||
$table->unsignedInteger('wireguard_listen_port')->default(51820);
|
||||
$table->string('container_network_pool')->default('10.210.0.0/16');
|
||||
$table->unsignedTinyInteger('container_network_prefix')->default(24);
|
||||
$table->json('namespaces')->nullable();
|
||||
$table->boolean('default_deny_containers')->default(true);
|
||||
$table->string('coold_version')->default('nightly');
|
||||
$table->string('corrosion_version')->default('v1.0.0');
|
||||
$table->unsignedInteger('corrosion_gossip_port')->default(8787);
|
||||
$table->unsignedInteger('corrosion_api_port')->default(8080);
|
||||
$table->boolean('builder_enabled')->default(true);
|
||||
$table->unsignedInteger('builder_capacity')->default(2);
|
||||
$table->string('builder_cpu_quota')->default('200%');
|
||||
$table->string('builder_memory_max')->default('2G');
|
||||
$table->unsignedInteger('builder_timeout_secs')->default(1800);
|
||||
$table->string('last_cli_action')->nullable();
|
||||
$table->string('last_cli_status')->nullable();
|
||||
$table->text('last_cli_summary')->nullable();
|
||||
$table->timestamp('last_cli_ran_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public static function createServersTable(): void
|
||||
{
|
||||
Schema::create('v5_servers', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('cluster_id')->nullable();
|
||||
$table->foreignId('created_by_user_id');
|
||||
$table->foreignId('private_key_id')->nullable();
|
||||
$table->string('name');
|
||||
$table->string('host');
|
||||
$table->string('ssh_user');
|
||||
$table->unsignedInteger('ssh_port')->default(22);
|
||||
$table->string('status')->default('installed');
|
||||
$table->timestamp('status_observed_at')->nullable();
|
||||
$table->string('ingress_type')->nullable();
|
||||
$table->string('ingress_status')->nullable();
|
||||
$table->boolean('has_coold')->default(false)->index();
|
||||
$table->boolean('is_ingress')->default(false)->index();
|
||||
$table->boolean('builder_enabled')->default(false);
|
||||
$table->unsignedInteger('builder_capacity')->default(0);
|
||||
$table->string('builder_cpu_quota')->default('200%');
|
||||
$table->string('node_address')->nullable();
|
||||
$table->unsignedInteger('wireguard_listen_port_override')->nullable();
|
||||
$table->string('wireguard_endpoint_override')->nullable();
|
||||
$table->string('wireguard_management_ip')->nullable();
|
||||
$table->string('wireguard_public_key')->nullable();
|
||||
$table->string('coold_version')->nullable();
|
||||
$table->string('agent_token_jti')->nullable();
|
||||
$table->timestamp('agent_token_expires_at')->nullable();
|
||||
$table->json('container_subnets')->nullable();
|
||||
$table->integer('canvas_x')->nullable();
|
||||
$table->integer('canvas_y')->nullable();
|
||||
$table->timestamp('last_bootstrapped_at')->nullable();
|
||||
$table->string('last_bootstrap_action')->nullable();
|
||||
$table->string('last_bootstrap_status')->nullable();
|
||||
$table->text('last_bootstrap_output')->nullable();
|
||||
$table->timestamp('last_bootstrap_ran_at')->nullable();
|
||||
$table->string('last_status_check')->nullable();
|
||||
$table->text('last_status_output')->nullable();
|
||||
$table->timestamp('last_status_checked_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public static function createContainerStatusesTable(): void
|
||||
{
|
||||
Schema::create('v5_container_statuses', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('server_id');
|
||||
$table->string('container_id');
|
||||
$table->string('container_name')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->string('status')->default('unknown');
|
||||
$table->text('status_message')->nullable();
|
||||
$table->timestamp('status_observed_at')->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['server_id', 'container_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public static function createApplicationsTable(): void
|
||||
{
|
||||
Schema::create('v5_applications', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('project_id');
|
||||
$table->foreignId('environment_id');
|
||||
$table->foreignId('server_id')->nullable();
|
||||
$table->foreignId('created_by_user_id');
|
||||
$table->string('name');
|
||||
$table->string('image');
|
||||
$table->string('container_name')->unique();
|
||||
$table->string('status')->default('creating');
|
||||
$table->text('status_message')->nullable();
|
||||
$table->timestamp('status_observed_at')->nullable();
|
||||
$table->string('runtime_container_id')->nullable();
|
||||
$table->string('mesh_namespace')->default('default');
|
||||
$table->boolean('ingress_enabled')->default(false);
|
||||
$table->unsignedSmallInteger('internal_port')->nullable();
|
||||
$table->integer('canvas_x')->default(0);
|
||||
$table->integer('canvas_y')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public static function createApplicationDomainsTable(): void
|
||||
{
|
||||
Schema::create('v5_application_domains', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('application_id');
|
||||
$table->string('domain');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['application_id', 'domain']);
|
||||
});
|
||||
}
|
||||
|
||||
public static function createResourceConnectionsTable(): void
|
||||
{
|
||||
Schema::create('v5_resource_connections', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('project_id');
|
||||
$table->foreignId('environment_id');
|
||||
$table->string('resource_one_type');
|
||||
$table->unsignedBigInteger('resource_one_id');
|
||||
$table->string('resource_two_type');
|
||||
$table->unsignedBigInteger('resource_two_id');
|
||||
$table->string('resource_pair_key');
|
||||
$table->foreignId('created_by_user_id');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'resource_pair_key']);
|
||||
});
|
||||
}
|
||||
|
||||
public static function createRevokedAgentTokensTable(): void
|
||||
{
|
||||
Schema::create('v5_revoked_agent_tokens', function ($table) {
|
||||
$table->id();
|
||||
$table->string('jti')->unique();
|
||||
$table->foreignId('server_id')->nullable();
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public static function createResourceConnectionRulesTable(): void
|
||||
{
|
||||
Schema::create('v5_resource_connection_rules', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('connection_id');
|
||||
$table->string('source_resource_type');
|
||||
$table->unsignedBigInteger('source_resource_id');
|
||||
$table->string('target_resource_type');
|
||||
$table->unsignedBigInteger('target_resource_id');
|
||||
$table->string('protocol')->default('tcp');
|
||||
$table->unsignedSmallInteger('port');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createAgentTokenIssuerKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/agent-token-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
function decodeAgentToken(string $token, string $publicKeyPath, ?stdClass &$headers = null): stdClass
|
||||
{
|
||||
// Firebase's JWT::decode only populates $headers when it is non-null on
|
||||
// entry (it opts out of clobbering by default), so seed a placeholder.
|
||||
if (func_num_args() >= 3) {
|
||||
$headers = new stdClass;
|
||||
}
|
||||
|
||||
return JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'), $headers);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createAgentTokenIssuerKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
it('mints the explicit advertised capability list by default, not the wildcard profile', function () {
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
$claims = decodeAgentToken($token, $this->publicKeyPath);
|
||||
|
||||
expect((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and((array) $claims->caps)->toContain('containers.create')
|
||||
->and((array) $claims->caps)->toContain('ingress.apply')
|
||||
->and((array) $claims->caps)->toContain('host.jwt.set')
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:default')
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:dev')
|
||||
->and((array) $claims->caps)->not->toContain('*');
|
||||
});
|
||||
|
||||
it('mints the escape-hatch capability profile when configured', function () {
|
||||
Config::set('flux.host_capability_profile', 'host-agent:default');
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
$claims = decodeAgentToken($token, $this->publicKeyPath);
|
||||
|
||||
expect((array) $claims->caps)->toBe(['host-agent:default']);
|
||||
});
|
||||
|
||||
it('includes a unique jti on every minted token', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
|
||||
$first = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
$second = decodeAgentToken($issuer->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($first->jti)->toBeString()->not->toBe('')
|
||||
->and($second->jti)->toBeString()->not->toBe('')
|
||||
->and($first->jti)->not->toBe($second->jti);
|
||||
});
|
||||
|
||||
it('derives the token lifetime from config', function () {
|
||||
Config::set('flux.host_token_ttl', 1234);
|
||||
|
||||
$before = time();
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(1234)
|
||||
->and($claims->exp)->toBeGreaterThanOrEqual($before + 1234);
|
||||
});
|
||||
|
||||
it('defaults host token lifetime to the flux max token lifetime', function () {
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(3600)
|
||||
->and(config('flux.host_token_refresh_threshold'))->toBeLessThan(config('flux.host_token_ttl'));
|
||||
});
|
||||
|
||||
it('clamps a below-floor ttl to 60 seconds', function () {
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10', null, 5), $this->publicKeyPath);
|
||||
|
||||
expect($claims->exp - $claims->iat)->toBe(60);
|
||||
});
|
||||
|
||||
it('sets the configured kid header for future key rotation', function () {
|
||||
Config::set('flux.jwt_kid', 'flux-2026');
|
||||
|
||||
$headers = null;
|
||||
decodeAgentToken(app(AgentTokenIssuer::class)->issue('100.64.0.10'), $this->publicKeyPath, $headers);
|
||||
|
||||
expect($headers->kid)->toBe('flux-2026')
|
||||
->and($headers->alg)->toBe('ES256');
|
||||
});
|
||||
|
||||
it('refuses to mint a host token before the server has a stable Flux host id', function () {
|
||||
$server = new V5Server([
|
||||
'node_address' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
expect(fn () => app(AgentTokenIssuer::class)->issueForServer($server))
|
||||
->toThrow(RuntimeException::class, 'Server is missing a valid Flux host id.');
|
||||
});
|
||||
|
||||
it('mints server host tokens for the stable server uuid, not the WireGuard management IP', function () {
|
||||
$server = new V5Server([
|
||||
'uuid' => 'server_abc123',
|
||||
'team_id' => 12,
|
||||
'cluster_id' => 34,
|
||||
'node_address' => '203.0.113.10',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$claims = decodeAgentToken(app(AgentTokenIssuer::class)->issueForServer($server), $this->publicKeyPath);
|
||||
|
||||
expect($claims->sub)->toBe('server_abc123')
|
||||
->and($claims->server_id)->toBe('server_abc123')
|
||||
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('warns when the private key file has insecure permissions', function () {
|
||||
chmod($this->privateKeyPath, 0644);
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(fn (string $message, array $context) => str_contains($message, 'insecure permissions')
|
||||
&& $context['expected'] === '0600');
|
||||
|
||||
app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createBroadcastPayloadServer(array $attributes = []): V5Server
|
||||
{
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
return V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
it('serializes a null cluster payload when the cluster is gone before the queued broadcast runs', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$event = new V5ClusterUpdated($team->id, 999);
|
||||
|
||||
expect($event->broadcastWith())->toBe(['cluster' => null]);
|
||||
});
|
||||
|
||||
it('omits the caddy ingress payload for a non-ingress server', function () {
|
||||
$server = createBroadcastPayloadServer(['is_ingress' => false]);
|
||||
|
||||
$event = new V5CanvasResourceUpdated($server->team_id, null, $server->id);
|
||||
|
||||
expect($event->broadcastWith()['caddyIngress'])->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes the caddy ingress payload for an ingress server', function () {
|
||||
$server = createBroadcastPayloadServer([
|
||||
'is_ingress' => true,
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => 'running',
|
||||
]);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($server->team_id, null, $server->id))->broadcastWith();
|
||||
|
||||
expect($payload['caddyIngress'])->toBe(
|
||||
app(CanvasResourceSerializer::class)->serializeCaddyIngress($server->fresh()),
|
||||
)->and($payload['caddyIngress']['id'])->toBe($server->uuid);
|
||||
});
|
||||
|
||||
it('broadcasts the same application shape as the initial-load canvas serializer', function () {
|
||||
$server = createBroadcastPayloadServer();
|
||||
$team = $server->team;
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Payload Parity', 'production');
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $server->created_by_user_id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-parity',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'container-123',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->domains()->create(['domain' => 'parity.example.com']);
|
||||
|
||||
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||
|
||||
$expected = app(CanvasResourceSerializer::class)->serializeApplication(
|
||||
V5Application::query()->with(['server', 'domains'])->findOrFail($application->id),
|
||||
);
|
||||
|
||||
expect($payload['application'])->toBe($expected)
|
||||
->and($payload['application']['id'])->toBe($application->uuid)
|
||||
->and($payload['application']['domains'])->toBe(['parity.example.com'])
|
||||
->and($payload['application']['projectUuid'])->toBe($project->uuid)
|
||||
->and($payload['application']['environmentUuid'])->toBe($environment->uuid);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -48,6 +49,143 @@ it('generates a caddy ingress compose file with health endpoint and application
|
||||
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
|
||||
});
|
||||
|
||||
it('generates byte-identical caddy routes for valid domains', function () {
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'nginx.example.com']),
|
||||
new ApplicationDomain(['domain' => 'www.nginx.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
|
||||
'http://nginx.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
'',
|
||||
'http://www.nginx.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
]));
|
||||
});
|
||||
|
||||
it('never emits domains with caddyfile injection payloads into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => "evil.com {\n} http://x"]),
|
||||
new ApplicationDomain(['domain' => "evil\n.com"]),
|
||||
new ApplicationDomain(['domain' => 'safe.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toHaveCount(1)
|
||||
->and($configuration['apps'][0]['caddyfile'])->not->toContain('evil')
|
||||
->and($configuration['apps'][0]['caddyfile'])->toBe(implode("\n", [
|
||||
'http://safe.example.com {',
|
||||
' reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080',
|
||||
'}',
|
||||
]));
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
|
||||
->twice();
|
||||
});
|
||||
|
||||
it('skips whole applications when every domain is malicious', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'evil.com { respond "pwned" }']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe domain.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('never emits unsafe container names or namespaces into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => "coolify {\n} injected",
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'app.example.com']),
|
||||
]));
|
||||
|
||||
$namespaceApplication = new Application([
|
||||
'name' => 'nginx-test-2',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => "default {\n}",
|
||||
'ingress_enabled' => true,
|
||||
'internal_port' => 8080,
|
||||
]);
|
||||
$namespaceApplication->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'other.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application, $namespaceApplication]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an unsafe container name or namespace.', Mockery::type('array'))
|
||||
->twice();
|
||||
});
|
||||
|
||||
it('never emits out-of-range internal ports into the configuration', function () {
|
||||
Log::spy();
|
||||
|
||||
$application = new Application([
|
||||
'name' => 'nginx-test',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => true,
|
||||
]);
|
||||
$application->internal_port = 70000;
|
||||
$application->setRelation('domains', new Collection([
|
||||
new ApplicationDomain(['domain' => 'app.example.com']),
|
||||
]));
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application]));
|
||||
|
||||
expect($configuration['apps'])->toBe([]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Skipping a caddy ingress route with an out-of-range internal port.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('does not generate app routes for applications without domains', function () {
|
||||
$application = new Application([
|
||||
'name' => 'private-app',
|
||||
@@ -103,6 +241,7 @@ it('does not generate app routes when the internal port is missing', function ()
|
||||
|
||||
it('applies caddy ingress configuration through flux instead of ssh', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'node_address' => '10.0.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
@@ -113,7 +252,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
Mockery::type('string'),
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
|
||||
[]
|
||||
@@ -122,7 +261,7 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', [
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => 'v5-caddy-ingress:80',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
@@ -132,17 +271,8 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', [
|
||||
'id' => 'v5-caddy-ingress:443',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 443,
|
||||
])
|
||||
->andReturn('Firewall rule applied.');
|
||||
->shouldNotReceive('applyFirewallRule')
|
||||
->with(Mockery::type('string'), Mockery::on(fn (array $rule): bool => ($rule['port'] ?? null) === 443));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StartCaddyIngress::run($server);
|
||||
@@ -162,27 +292,26 @@ it('does not start caddy ingress for non-ingress servers', function () {
|
||||
|
||||
it('stops caddy ingress through flux instead of ssh', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'node_address' => '10.0.0.10',
|
||||
'capabilities' => [],
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with('100.64.0.10', 'caddy')
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', 'v5-caddy-ingress:80')
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
|
||||
->andReturn('Firewall rule removed.');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with('100.64.0.10', 'v5-caddy-ingress:443')
|
||||
->andReturn('Firewall rule removed.');
|
||||
->shouldNotReceive('revokeFirewallRule')
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:443');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StopCaddyIngress::run($server);
|
||||
@@ -190,6 +319,78 @@ it('stops caddy ingress through flux instead of ssh', function () {
|
||||
expect($result)->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('does not stop caddy ingress for servers that never had ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldNotReceive('stopIngress');
|
||||
$fluxClient->shouldNotReceive('revokeFirewallRule');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Server is not an ingress server.');
|
||||
});
|
||||
|
||||
it('revokes the caddy firewall rule before stopping the ingress container', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'v5-caddy-ingress:80')
|
||||
->ordered()
|
||||
->andReturn('Firewall rule removed.');
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->ordered()
|
||||
->andThrow(new RuntimeException('podman rm failed'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(fn () => StopCaddyIngress::run($server))->toThrow(RuntimeException::class, 'podman rm failed');
|
||||
});
|
||||
|
||||
it('tolerates already-revoked caddy firewall rules when stopping ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('firewall rule not found'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('reports an unknown ingress status until coold confirms one', function () {
|
||||
$installed = new Server(['capabilities' => ['ingress']]);
|
||||
$installed->status = 'installed';
|
||||
|
||||
expect($installed->ingressStatus())->toBe('unknown');
|
||||
|
||||
$running = new Server(['capabilities' => ['ingress'], 'ingress_status' => 'running']);
|
||||
|
||||
expect($running->ingressStatus())->toBe('running');
|
||||
});
|
||||
|
||||
it('includes flux error response details when dispatch returns a non success status', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
@@ -315,6 +516,8 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['id' => 'container-123']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container started.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['State' => ['Running' => true]]],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container stopped.']],
|
||||
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Container removed.']],
|
||||
];
|
||||
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
|
||||
|
||||
@@ -332,7 +535,9 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
'restart_policy' => 'unless-stopped',
|
||||
]))->toBe('container-123')
|
||||
->and($fluxClient->startContainer('100.64.0.10', 'container-123'))->toBe('Container started.')
|
||||
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]]);
|
||||
->and($fluxClient->inspectContainer('100.64.0.10', 'container-123'))->toBe(['State' => ['Running' => true]])
|
||||
->and($fluxClient->stopContainer('100.64.0.10', 'container-123'))->toBe('Container stopped.')
|
||||
->and($fluxClient->removeContainer('100.64.0.10', 'container-123', true))->toBe('Container removed.');
|
||||
});
|
||||
|
||||
$request = file_get_contents($requestPath) ?: '';
|
||||
@@ -343,7 +548,10 @@ it('dispatches image pull and container lifecycle primitives for v5 apps', funct
|
||||
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
|
||||
->toContain('"dns_search":["default.coolify.internal"]')
|
||||
->toContain('"type":"containers.start"')
|
||||
->toContain('"type":"containers.inspect"');
|
||||
->toContain('"type":"containers.inspect"')
|
||||
->toContain('"type":"containers.stop"')
|
||||
->toContain('"type":"containers.delete"')
|
||||
->toContain('"force":true');
|
||||
});
|
||||
|
||||
it('dispatches firewall allow through the firewall allow primitive', function () {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('detects unsupported coold verbs from flux http 501 responses', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
// Flux rejects verbs the node's coold did not advertise with HTTP 501 and
|
||||
// "primitive <verb> is not supported by host" (coold repo:
|
||||
// flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245).
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 501,
|
||||
'message' => 'primitive firewall.allow is not supported by host',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 501 Not Implemented\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->applyFirewallRule('100.64.0.10', [
|
||||
'id' => 'rule-1',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
]);
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->verb)->toBe('firewall.allow')
|
||||
->and($caught->getMessage())->toBe('primitive firewall.allow is not supported by host');
|
||||
});
|
||||
|
||||
it('detects unsupported coold verbs from the flux message even without a 501 status', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 502,
|
||||
'message' => 'primitive coold.logs is not supported by host',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 502 Bad Gateway\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->cooldLogs('100.64.0.10');
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->verb)->toBe('coold.logs');
|
||||
});
|
||||
|
||||
it('keeps generic flux dispatch failures as plain runtime exceptions', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$body = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'error',
|
||||
'code' => 500,
|
||||
'message' => 'apply firewall rule: nft exited with status 1',
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$caught = null;
|
||||
|
||||
withCooldVerbContractFluxSocket(
|
||||
"HTTP/1.1 500 Internal Server Error\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
function () use (&$caught): void {
|
||||
try {
|
||||
(new FluxClient)->applyFirewallRule('100.64.0.10', [
|
||||
'id' => 'rule-1',
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
]);
|
||||
} catch (RuntimeException $exception) {
|
||||
$caught = $exception;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect($caught)->toBeInstanceOf(RuntimeException::class)
|
||||
->not->toBeInstanceOf(UnsupportedCooldVerb::class)
|
||||
->and($caught->getMessage())->toBe('apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('marks caddy ingress running with a warning when the node coold lacks firewall support', function () {
|
||||
Event::fake();
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
|
||||
$server = Server::query()->create([
|
||||
'uuid' => 'cooldverb-ingress-server-uuid',
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'ingress-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'capabilities' => ['ingress'],
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$result = StartCaddyIngress::run($server);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($result)->toBe('Caddy ingress applied.')
|
||||
->and($server->ingress_type)->toBe('caddy')
|
||||
->and($server->ingress_status)->toBe('running')
|
||||
->and($server->last_status_check)->toBe('flux')
|
||||
->and($server->last_status_output)->toContain('does not support firewall.allow');
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
it('still surfaces supported-but-failed firewall errors when starting caddy ingress', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andReturn('Caddy ingress applied.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(fn () => StartCaddyIngress::run($server))
|
||||
->toThrow(RuntimeException::class, 'apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('stops caddy ingress even when the node coold lacks firewall support', function () {
|
||||
$server = new Server([
|
||||
'uuid' => 'test-server-uuid',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
expect(StopCaddyIngress::run($server))->toBe('Caddy ingress stopped.');
|
||||
});
|
||||
|
||||
it('skips resource connection firewall sync rules when the node coold lacks firewall support', function () {
|
||||
$sync = new ConnectionFirewallSync;
|
||||
|
||||
$oldRule = [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
|
||||
'hostId' => '100.64.0.11',
|
||||
'rule' => [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:5432',
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
],
|
||||
];
|
||||
$newRule = [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
|
||||
'hostId' => '100.64.0.10',
|
||||
'rule' => [
|
||||
'id' => 'v5-resource-connection:1:1:2:tcp:6379',
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-redis',
|
||||
'proto' => 'tcp',
|
||||
'port' => 6379,
|
||||
],
|
||||
];
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $oldRule['id'])
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $newRule['rule'])
|
||||
->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
|
||||
$sync->sync($fluxClient, collect([$oldRule]), collect([$newRule]));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
function withCooldVerbContractFluxSocket(string $response, Closure $callback): void
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($socketServer, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
@unlink($socketPath);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -33,3 +38,205 @@ it('builds an nginx container spec for the coold mesh runtime', function () {
|
||||
->not->toHaveKey('command')
|
||||
->not->toHaveKey('privileged');
|
||||
});
|
||||
|
||||
it('stops and removes the created container when starting it fails', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andThrow(new RuntimeException('podman start exited with status 125'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andReturn('Container stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123', true)
|
||||
->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('podman start exited with status 125')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not clean up any container when creating the container fails', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient
|
||||
->shouldReceive('createContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Flux did not return a container id.'));
|
||||
$fluxClient->shouldNotReceive('startContainer');
|
||||
$fluxClient->shouldNotReceive('stopContainer');
|
||||
$fluxClient->shouldNotReceive('removeContainer');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('Flux did not return a container id.')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the original deploy failure and logs a warning when cleanup itself fails', function () {
|
||||
Event::fake();
|
||||
Log::spy();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('original deploy failure'));
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('stop failed'));
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('remove failed'));
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('original deploy failure')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Could not stop the container created by a failed v5 deploy.', Mockery::type('array'))
|
||||
->once();
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('Could not remove the container created by a failed v5 deploy.', Mockery::type('array'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('cleans up the container when it does not stay running after start', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
|
||||
$fluxClient
|
||||
->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->andReturn(['State' => ['Running' => false]]);
|
||||
$fluxClient
|
||||
->shouldReceive('stopContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123')
|
||||
->andReturn('Container stopped.');
|
||||
$fluxClient
|
||||
->shouldReceive('removeContainer')
|
||||
->once()
|
||||
->with(Mockery::type('string'), 'container-123', true)
|
||||
->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('failed')
|
||||
->and($result->status_message)->toBe('Container did not stay running.')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('persists the container id only for a successful deploy', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
$fluxClient->shouldReceive('startContainer')->once()->andReturn('Container started.');
|
||||
$fluxClient
|
||||
->shouldReceive('inspectContainer')
|
||||
->once()
|
||||
->andReturn(['State' => ['Running' => true]]);
|
||||
$fluxClient->shouldNotReceive('stopContainer');
|
||||
$fluxClient->shouldNotReceive('removeContainer');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($result->status)->toBe('running')
|
||||
->and($result->runtime_container_id)->toBe('container-123');
|
||||
});
|
||||
|
||||
it('persists the runtime container id immediately after create, before start', function () {
|
||||
Event::fake();
|
||||
$application = createNginxDeploymentApplication();
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('pullImage')->once()->andReturn('Image pulled.');
|
||||
$fluxClient->shouldReceive('createContainer')->once()->andReturn('container-123');
|
||||
|
||||
$persistedBeforeStart = null;
|
||||
$fluxClient
|
||||
->shouldReceive('startContainer')
|
||||
->once()
|
||||
->andReturnUsing(function () use ($application, &$persistedBeforeStart) {
|
||||
// Read the row straight from the database to prove the id is durable
|
||||
// before start runs, not just held in the in-memory model.
|
||||
$persistedBeforeStart = Application::query()->find($application->getKey())?->runtime_container_id;
|
||||
|
||||
throw new RuntimeException('podman start exited with status 125');
|
||||
});
|
||||
$fluxClient->shouldReceive('stopContainer')->once()->andReturn('Container stopped.');
|
||||
$fluxClient->shouldReceive('removeContainer')->once()->andReturn('Container removed.');
|
||||
|
||||
$result = (new DeployNginxApplication($fluxClient))->handle($application);
|
||||
|
||||
expect($persistedBeforeStart)->toBe('container-123')
|
||||
->and($result->status)->toBe('failed')
|
||||
->and($result->runtime_container_id)->toBeNull();
|
||||
});
|
||||
|
||||
function createNginxDeploymentApplication(): Application
|
||||
{
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
|
||||
$server = Server::query()->forceCreate([
|
||||
'uuid' => 'nginx-deployment-server-uuid',
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'node-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
return Application::query()->forceCreate([
|
||||
'uuid' => 'nginx-deployment-application-uuid',
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'status' => 'creating',
|
||||
'mesh_namespace' => 'default',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Policies\V5\ApplicationPolicy;
|
||||
use App\Policies\V5\ClusterPolicy;
|
||||
use App\Policies\V5\ResourceConnectionPolicy;
|
||||
use App\Policies\V5\ServerPolicy;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Build a user whose team membership carries the given role, so the policies'
|
||||
* $user->isAdminOfTeam() role gate can be exercised without touching the DB.
|
||||
*/
|
||||
function v5PolicyUser(string $role = 'owner', int $teamId = 10): User
|
||||
{
|
||||
$user = (new User)->forceFill(['id' => 1]);
|
||||
$team = (new Team)->forceFill(['id' => $teamId]);
|
||||
$pivot = new Pivot;
|
||||
$pivot->role = $role;
|
||||
$team->setRelation('pivot', $pivot);
|
||||
$user->setRelation('teams', collect([$team]));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function v5PolicyMemberUser(int $teamId = 10): User
|
||||
{
|
||||
return v5PolicyUser('member', $teamId);
|
||||
}
|
||||
|
||||
function v5PolicyTeam(int $id = 10): Team
|
||||
{
|
||||
return (new Team)->forceFill(['id' => $id]);
|
||||
}
|
||||
|
||||
it('allows adding a server to a cluster of the current team', function () {
|
||||
$team = v5PolicyTeam();
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
|
||||
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $cluster);
|
||||
|
||||
expect($response->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies adding a server to another teams cluster as forbidden instead of not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
|
||||
|
||||
$response = (new ServerPolicy)->create(v5PolicyUser(), $team, $foreignCluster);
|
||||
|
||||
// A plain deny carries no status override, so the framework renders the
|
||||
// historical 403 instead of a 404.
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
});
|
||||
|
||||
it('denies server update and delete outside the current team as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => 99]);
|
||||
$foreignServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'cluster_id' => 5]);
|
||||
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
$update = $policy->update(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
|
||||
$delete = $policy->delete(v5PolicyUser(), $foreignServer, $team, $foreignCluster);
|
||||
|
||||
expect($update->denied())->toBeTrue()
|
||||
->and($update->status())->toBe(404)
|
||||
->and($delete->denied())->toBeTrue()
|
||||
->and($delete->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('denies server update for a server outside the addressed cluster as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$serverInOtherCluster = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 6]);
|
||||
|
||||
$response = (new ServerPolicy)->update(v5PolicyUser(), $serverInOtherCluster, $team, $cluster);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('allows moving the caddy ingress canvas card only for ingress servers of the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
$ingressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => true]);
|
||||
|
||||
expect($policy->updateCanvasPosition(v5PolicyUser(), $ingressServer, $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies moving the canvas card of a non-ingress server as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$nonIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'is_ingress' => false]);
|
||||
|
||||
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $nonIngressServer, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('denies moving another teams ingress canvas card as not found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$foreignIngressServer = (new V5Server)->forceFill(['id' => 7, 'team_id' => 99, 'is_ingress' => true]);
|
||||
|
||||
$response = (new ServerPolicy)->updateCanvasPosition(v5PolicyUser(), $foreignIngressServer, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('scopes cluster viewing and deletion to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ClusterPolicy;
|
||||
|
||||
$ownCluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$foreignCluster = (new Cluster)->forceFill(['id' => 6, 'team_id' => 99]);
|
||||
|
||||
expect($policy->view(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownCluster, $team)->allowed())->toBeTrue();
|
||||
|
||||
$view = $policy->view(v5PolicyUser(), $foreignCluster, $team);
|
||||
$delete = $policy->delete(v5PolicyUser(), $foreignCluster, $team);
|
||||
|
||||
expect($view->denied())->toBeTrue()
|
||||
->and($view->status())->toBe(404)
|
||||
->and($delete->denied())->toBeTrue()
|
||||
->and($delete->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('scopes application mutations to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ApplicationPolicy;
|
||||
|
||||
$ownApplication = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$foreignApplication = (new V5Application)->forceFill(['id' => 4, 'team_id' => 99]);
|
||||
|
||||
expect($policy->update(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
|
||||
->and($policy->updateIngress(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownApplication, $team)->allowed())->toBeTrue();
|
||||
|
||||
foreach (['update', 'updateIngress', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}(v5PolicyUser(), $foreignApplication, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes resource connection mutations to the current team with not-found denials', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$policy = new ResourceConnectionPolicy;
|
||||
|
||||
$ownConnection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$foreignConnection = (new ResourceConnection)->forceFill(['id' => 4, 'team_id' => 99]);
|
||||
|
||||
expect($policy->update(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue()
|
||||
->and($policy->delete(v5PolicyUser(), $ownConnection, $team)->allowed())->toBeTrue();
|
||||
|
||||
foreach (['update', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}(v5PolicyUser(), $foreignConnection, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies server mutations for a member of the current team as forbidden not not-found', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5, 'is_ingress' => true]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ServerPolicy;
|
||||
|
||||
expect($policy->create($member, $team, $cluster)->denied())->toBeTrue()
|
||||
->and($policy->create($member, $team, $cluster)->status())->toBeNull();
|
||||
|
||||
foreach (['update', 'delete', 'check', 'bootstrap'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $server, $team, $cluster);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
|
||||
expect($policy->updateCanvasPosition($member, $server, $team)->denied())->toBeTrue()
|
||||
->and($policy->updateCanvasPosition($member, $server, $team)->status())->toBeNull();
|
||||
});
|
||||
|
||||
it('still lets a member read server diagnostics of the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$server = (new V5Server)->forceFill(['id' => 7, 'team_id' => $team->id, 'cluster_id' => 5]);
|
||||
|
||||
expect((new ServerPolicy)->viewDiagnostics(v5PolicyMemberUser(), $server, $team, $cluster)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies cluster create and delete for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$cluster = (new Cluster)->forceFill(['id' => 5, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ClusterPolicy;
|
||||
|
||||
expect($policy->create($member, $team)->denied())->toBeTrue()
|
||||
->and($policy->create($member, $team)->status())->toBeNull()
|
||||
->and($policy->delete($member, $cluster, $team)->denied())->toBeTrue()
|
||||
->and($policy->delete($member, $cluster, $team)->status())->toBeNull();
|
||||
|
||||
// Members may still read.
|
||||
expect($policy->view($member, $cluster, $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows an admin to create a cluster in the current team', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
|
||||
expect((new ClusterPolicy)->create(v5PolicyUser('admin'), $team)->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies application mutations for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$application = (new V5Application)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ApplicationPolicy;
|
||||
|
||||
foreach (['update', 'updateIngress', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $application, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('denies resource connection mutations for a member of the current team as forbidden', function () {
|
||||
$team = v5PolicyTeam(10);
|
||||
$connection = (new ResourceConnection)->forceFill(['id' => 3, 'team_id' => $team->id]);
|
||||
$member = v5PolicyMemberUser();
|
||||
$policy = new ResourceConnectionPolicy;
|
||||
|
||||
foreach (['update', 'delete'] as $ability) {
|
||||
$response = $policy->{$ability}($member, $connection, $team);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Jobs\V5DeployApplicationJob;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('locks v5 deploy jobs per application for the job timeout plus a margin', function () {
|
||||
$job = new V5DeployApplicationJob(42);
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe('42')
|
||||
->and($job->uniqueFor)->toBe($job->timeout + 60);
|
||||
});
|
||||
|
||||
it('locks v5 bootstrap jobs per server aligned with the controller bootstrap claim window', function () {
|
||||
$job = new V5BootstrapServerJob(7, 13);
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe('13')
|
||||
->and($job->uniqueFor)->toBe(V5BootstrapServerJob::TIMEOUT_SECONDS + 300);
|
||||
});
|
||||
|
||||
it('does not queue a second v5 deploy job while one is pending for the same application', function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Queue::fake();
|
||||
|
||||
V5DeployApplicationJob::dispatch(101);
|
||||
V5DeployApplicationJob::dispatch(101);
|
||||
V5DeployApplicationJob::dispatch(202);
|
||||
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, 2);
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 101);
|
||||
Queue::assertPushed(V5DeployApplicationJob::class, fn (V5DeployApplicationJob $job): bool => $job->applicationId === 202);
|
||||
});
|
||||
|
||||
it('does not queue a second v5 bootstrap job while one is pending for the same server', function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Queue::fake();
|
||||
|
||||
V5BootstrapServerJob::dispatch(1, 55);
|
||||
V5BootstrapServerJob::dispatch(2, 55);
|
||||
V5BootstrapServerJob::dispatch(1, 66);
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, 2);
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 55);
|
||||
Queue::assertPushed(V5BootstrapServerJob::class, fn (V5BootstrapServerJob $job): bool => $job->serverId === 66);
|
||||
});
|
||||
|
||||
it('queues v5 canvas and cluster broadcasts after commit instead of pushing on the request thread', function () {
|
||||
$canvasEvent = new V5CanvasResourceUpdated(1, 2);
|
||||
$clusterEvent = new V5ClusterUpdated(1, 2);
|
||||
|
||||
expect($canvasEvent)->toBeInstanceOf(ShouldBroadcast::class)
|
||||
->not->toBeInstanceOf(ShouldBroadcastNow::class)
|
||||
->and($canvasEvent->afterCommit)->toBeTrue()
|
||||
->and($clusterEvent)->toBeInstanceOf(ShouldBroadcast::class)
|
||||
->not->toBeInstanceOf(ShouldBroadcastNow::class)
|
||||
->and($clusterEvent->afterCommit)->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the manual v5 realtime test ping broadcasting immediately', function () {
|
||||
expect(new V5RealtimeTestEvent(1, 'ping'))->toBeInstanceOf(ShouldBroadcastNow::class);
|
||||
});
|
||||
@@ -50,6 +50,11 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline before dot' => ["evil\n.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'newline inside label' => ["evil\ncom", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'carriage return' => ["evil\r.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'tab character' => ["evil\t.com", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'caddy block injection' => ["evil.com {\n} http://x", 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
]);
|
||||
|
||||
it('accepts empty hostname', function () {
|
||||
|
||||
Reference in new issue
Block a user