feat(v5): deploy nginx applications through Flux

Create nginx containers through Flux image and container primitives instead of SSH, and allow selecting a custom nginx image from the dashboard.
This commit is contained in:
Andras Bacsai
2026-06-21 22:39:16 +02:00
parent ec3e5dc9eb
commit e543d2b376
7 changed files with 315 additions and 103 deletions
+76 -12
View File
@@ -1078,9 +1078,7 @@ it('creates an nginx v5 application on the first installed team server', functio
'last_bootstrapped_at' => now(),
]);
Process::fake([
'*' => Process::result(output: "nginx-container-id\n"),
]);
fakeSuccessfulNginxFluxDeployment();
$this
->actingAs($user)
@@ -1109,6 +1107,46 @@ it('creates an nginx v5 application on the first installed team server', functio
->exists())->toBeTrue();
});
it('creates an nginx v5 application with a custom image', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
V5Server::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => [],
'last_bootstrapped_at' => now(),
]);
fakeSuccessfulNginxFluxDeployment(image: 'docker.io/library/httpd:alpine');
$this
->actingAs($user)
->withSession([
'currentTeam' => $team,
'v5.selectedProjectUuid' => $project->uuid,
'v5.selectedEnvironmentUuid' => $environment->uuid,
])
->postJson('/v5/applications/nginx', [
'image' => 'docker.io/library/httpd:alpine',
])
->assertCreated()
->assertJsonPath('application.image', 'docker.io/library/httpd:alpine');
expect(V5Application::query()
->where('image', 'docker.io/library/httpd:alpine')
->where('runtime_container_id', 'nginx-container-id')
->exists())->toBeTrue();
});
it('creates an nginx v5 application on the selected team server', function () {
createSharedUserAndTeamTables();
@@ -1140,9 +1178,7 @@ it('creates an nginx v5 application on the selected team server', function () {
'last_bootstrapped_at' => now(),
]);
Process::fake([
'*' => Process::result(output: "nginx-container-id\n"),
]);
fakeSuccessfulNginxFluxDeployment();
$this
->actingAs($user)
@@ -1198,9 +1234,7 @@ it('places a new nginx v5 application next to existing canvas nodes', function (
'canvas_y' => 0,
]);
Process::fake([
'*' => Process::result(output: "nginx-container-id\n"),
]);
fakeSuccessfulNginxFluxDeployment();
$this
->actingAs($user)
@@ -1234,9 +1268,9 @@ it('marks an nginx v5 application failed when the launch command fails', functio
'last_bootstrapped_at' => now(),
]);
Process::fake([
'*' => Process::result(errorOutput: 'podman failed', exitCode: 1),
]);
$this->mock(FluxClient::class, function (MockInterface $mock): void {
$mock->shouldReceive('pullImage')->once()->andThrow(new RuntimeException('podman failed'));
});
$this
->actingAs($user)
@@ -4195,7 +4229,10 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
->toContain('Add nginx')
->toContain('Select nginx server')
->toContain('selectedNginxServerId')
->toContain('nginxImage')
->toContain('docker.io/library/nginx:alpine')
->toContain('server_id: selectedNginxServerId || null')
->toContain('image: nginxImage.trim() || DEFAULT_NGINX_IMAGE')
->toContain('Center')
->toContain('Delete')
->toContain('App configuration')
@@ -4892,6 +4929,33 @@ function fakeFluxHealth(bool $available = true, string $message = 'Flux is runni
}));
}
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 createSharedUserAndTeamTables(): void
{
Schema::create('users', function ($table) {
@@ -271,6 +271,47 @@ it('dispatches container inventory through the containers list primitive', funct
->not->toContain('list_containers');
});
it('dispatches image pull and container lifecycle primitives for v5 apps', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
$responses = [
['request_id' => 'test-request', 'status' => 'ok', 'data' => ['output' => 'Image pulled.']],
['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]]],
];
$requestPath = storage_path('framework/testing/flux-request-'.bin2hex(random_bytes(8)).'.txt');
withFakeFluxSocketCapturingRequests($responses, $requestPath, function (): void {
$fluxClient = new FluxClient;
expect($fluxClient->pullImage('100.64.0.10', 'docker.io/library/nginx:alpine'))->toBe('Image pulled.')
->and($fluxClient->createContainer('100.64.0.10', [
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns' => ['10.210.0.1'],
'dns_search' => ['default.coolify.internal'],
'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]]);
});
$request = file_get_contents($requestPath) ?: '';
@unlink($requestPath);
expect($request)->toContain('"type":"images.pull"')
->toContain('"type":"containers.create"')
->toContain('"network_aliases":["coolify-v5-nginx-test"]')
->toContain('"dns_search":["default.coolify.internal"]')
->toContain('"type":"containers.start"')
->toContain('"type":"containers.inspect"');
});
it('dispatches firewall allow through the firewall allow primitive', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
@@ -339,6 +380,72 @@ it('dispatches firewall revoke through the firewall revoke primitive', function
->toContain('"id":"rule-123"');
});
function withFakeFluxSocketCapturingRequests(array $responsePayloads, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
$server = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
expect($server)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
$pid = pcntl_fork();
if ($pid === 0) {
$capturedRequests = '';
foreach ($responsePayloads as $payload) {
$connection = stream_socket_accept($server, 5);
if ($connection === false) {
continue;
}
$request = '';
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
$request .= fread($connection, 8192);
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches) === 1) {
$remaining = (int) $matches[1] - strlen(substr($request, strpos($request, "\r\n\r\n") + 4));
while ($remaining > 0 && ! feof($connection)) {
$chunk = fread($connection, $remaining);
$request .= $chunk;
$remaining -= strlen($chunk);
}
}
$capturedRequests .= $request."\n---REQUEST---\n";
$body = json_encode($payload, JSON_THROW_ON_ERROR);
fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n{$body}");
fclose($connection);
}
file_put_contents($requestPath, $capturedRequests);
fclose($server);
exit(0);
}
fclose($server);
Config::set('flux.unix_socket_path', $socketPath);
Config::set('flux.health_timeout_seconds', 1.0);
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);
}
}
function withFakeFluxSocketCapturingRequest(string $response, string $requestPath, Closure $callback): void
{
$directory = storage_path('framework/testing');
@@ -2,31 +2,34 @@
use App\Actions\V5\Application\DeployNginxApplication;
use App\Models\V5\Application;
use App\Services\Flux\FluxClient;
use Tests\TestCase;
uses(TestCase::class);
it('verifies nginx is running before marking the application running', function () {
it('builds an nginx container spec for the coold mesh runtime', function () {
$application = new Application([
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'mesh_namespace' => 'default',
'status' => 'creating',
]);
$action = new DeployNginxApplication;
$method = new ReflectionMethod($action, 'remoteCommand');
$action = new DeployNginxApplication(Mockery::mock(FluxClient::class));
$method = new ReflectionMethod($action, 'containerSpec');
$method->setAccessible(true);
$remoteCommand = $method->invoke($action, $application);
$spec = $method->invoke($action, $application);
expect($remoteCommand)
->toContain('if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi')
->toContain("--network 'coolify-default-mesh'")
->toContain("--network-alias 'coolify-v5-nginx-test'")
->toContain('$podman inspect')
->not->toContain('docker run')
->not->toContain('docker inspect')
->toContain('.State.Running')
->toContain('Container did not stay running')
->toContain('exit 1');
expect($spec)
->toMatchArray([
'name' => 'coolify-v5-nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'networks' => ['coolify-default-mesh'],
'network_aliases' => ['coolify-v5-nginx-test'],
'dns_search' => ['default.coolify.internal'],
'restart_policy' => 'unless-stopped',
])
->not->toHaveKey('command')
->not->toHaveKey('privileged');
});