feat(v5): add dashboard ingress routing controls

Add V5 dashboard controls and validation for enabling server-backed app ingress, track generic ingress type/status fields, and improve Flux dispatch timeout/error handling.
This commit is contained in:
Andras Bacsai committed 2026-06-20 23:10:48 +02:00
1 parent ad0bcc8a4a
commit 1c091e9e36
22 files changed
+1120 -287

No files matched your search

@@ -7,3 +7,10 @@ it('does not render server capability summaries on the cluster page', function (
->not->toContain('Capabilities:')
->not->toContain('normalizeCapabilities');
});
it('shows server update error messages returned by the v5 api', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain("payload?.message ?? 'Unable to update this server. Please try again.'");
});
+383 -42
View File
@@ -120,6 +120,94 @@ it('does not render v5 application status messages on dashboard cards', function
->not->toContain('{application.statusMessage}</p>');
});
it('uses the shared dialog and button components for the application ingress modal', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardSource)
->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 () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardSource)
->toContain('savingIngressApplicationId')
->toContain("isApplicationIngressSaving ? 'Saving...'")
->toContain("isSavingIngress ? 'Saving...' : 'Enable ingress'");
});
it('generates http-only caddy routes for application ingress', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$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' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$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-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->with(
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
&& ! str_contains($apps[0]['caddyfile'], 'https://')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
)
->andReturn('Caddy ingress applied.');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertSuccessful();
});
it('shows a dashboard refresh button next to the center button', function () {
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
@@ -345,7 +433,7 @@ it('creates v5 server tables in the shared database', function () {
]))->toBeTrue();
});
it('adds v5 server canvas columns for movable caddy ingress nodes', function () {
it('creates v5 server canvas columns for movable caddy ingress nodes', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
@@ -357,16 +445,13 @@ it('adds v5 server canvas columns for movable caddy ingress nodes', function ()
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$canvasMigration = include database_path('migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php');
$canvasMigration->up();
expect(Schema::hasColumns('v5_servers', [
'canvas_x',
'canvas_y',
]))->toBeTrue();
});
it('adds v5 server caddy ingress container status column', function () {
it('creates v5 server ingress columns', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
@@ -378,28 +463,16 @@ it('adds v5 server caddy ingress container status column', function () {
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
[$user, $team] = createV5UserWithTeam();
$server = 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',
'capabilities' => ['coold', 'ingress'],
]);
$statusMigration = include database_path('migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php');
$statusMigration->up();
expect(Schema::hasColumn('v5_servers', 'caddy_ingress_status'))->toBeTrue()
->and($server->refresh()->caddy_ingress_status)->toBe('running');
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');
@@ -428,6 +501,8 @@ it('creates v5 application tables for dashboard canvas nodes', function () {
'status_message',
'runtime_container_id',
'mesh_namespace',
'ingress_enabled',
'internal_port',
'canvas_x',
'canvas_y',
'created_at',
@@ -452,9 +527,6 @@ it('creates v5 application domain tables for zero or more inbound routes', funct
$applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php');
$applicationMigration->up();
$domainMigration = include database_path('migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php');
$domainMigration->up();
expect(Schema::hasTable('v5_application_domains'))->toBeTrue()
->and(Schema::hasColumns('v5_application_domains', [
'id',
@@ -474,6 +546,7 @@ it('creates generic v5 resource connection tables', function () {
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');
@@ -534,10 +607,14 @@ it('keeps v5 server fields in the initial migration', function () {
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();
@@ -554,6 +631,8 @@ it('includes v5 tables in the dev testing schema', function () {
->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')
@@ -564,7 +643,8 @@ it('includes v5 tables in the dev testing schema', function () {
->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('"caddy_ingress_status" TEXT')
->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')
@@ -575,10 +655,11 @@ it('includes v5 tables in the dev testing schema', function () {
->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_141231_add_canvas_position_to_v5_servers_table')
->and($schema)->toContain('2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_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)->toContain('2026_06_20_072818_v5_add_ingress_routing_to_applications_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')
@@ -829,7 +910,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'canvas_x' => -160,
'canvas_y' => 240,
@@ -842,7 +923,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'exited',
'ingress_status' => 'exited',
'capabilities' => ['coold', 'ingress'],
]);
V5Server::query()->create([
@@ -864,6 +945,7 @@ it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
->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)
@@ -1475,7 +1557,7 @@ it('applies flux caddy ingress container status updates without changing server
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'wireguard_management_ip' => '100.64.0.5',
]);
@@ -1492,7 +1574,8 @@ it('applies flux caddy ingress container status updates without changing server
expect($resource)->toBeInstanceOf(V5Server::class)
->and($server->refresh()->status)->toBe('installed')
->and($server->caddy_ingress_status)->toBe('exited')
->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();
@@ -1762,7 +1845,7 @@ it('refreshes v5 caddy ingress state from flux container inventory', function ()
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'caddy_ingress_status' => 'running',
'ingress_status' => 'running',
'capabilities' => ['coold', 'ingress'],
'wireguard_management_ip' => '100.64.0.6',
]);
@@ -1793,10 +1876,12 @@ it('refreshes v5 caddy ingress state from flux container inventory', function ()
->postJson('/v5/applications/refresh')
->assertSuccessful()
->assertJsonPath('caddyIngresses.0.id', (string) $server->id)
->assertJsonPath('caddyIngresses.0.type', 'caddy')
->assertJsonPath('caddyIngresses.0.status', 'exited');
expect($server->refresh()->status)->toBe('installed')
->and($server->caddy_ingress_status)->toBe('exited')
->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 ingress state refreshed from coold.');
@@ -2205,6 +2290,7 @@ it('adds a v5 server to a cluster for the current team', function () {
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%')
->assertJsonPath('cluster.servers.0.ingressEnabled', false)
->assertJsonPath('cluster.servers.0.ingressType', null)
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'builder'])
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821')
@@ -2251,14 +2337,17 @@ it('adds a v5 server with caddy ingress enabled', function () {
'builder_enabled' => false,
'builder_capacity' => 0,
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertCreated()
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
$server = V5Server::query()->where('name', 'edge-01')->first();
expect($server->capabilities)->toBe(['coold', 'ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->isIngress())->toBeTrue();
});
@@ -3095,19 +3184,81 @@ it('updates editable v5 server caddy ingress capability independently from build
'builder_capacity' => 2,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']);
$server->refresh();
expect($server->capabilities)->toBe(['coold', 'ingress'])
->and($server->ingress_type)->toBe('caddy')
->and($server->builder_enabled)->toBeFalse()
->and($server->isIngress())->toBeTrue();
});
it('rejects application ingress when server ingress is disabled', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$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' => 'app-01',
'host' => '203.0.113.21',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.11',
'last_bootstrapped_at' => now(),
]);
$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' => 'private-app',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-private-app',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('applyCaddyIngress');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertUnprocessable()
->assertJsonPath('message', 'Enable ingress on the server before enabling app ingress.');
expect($application->refresh()->ingress_enabled)->toBeFalse()
->and($application->domains()->count())->toBe(0);
});
it('enables application ingress without publishing domains by default', function () {
createSharedUserAndTeamTables();
@@ -3179,6 +3330,64 @@ it('enables application ingress without publishing domains by default', function
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('validates application ingress domains', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$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' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$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-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient->shouldNotReceive('applyCaddyIngress');
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['https://bad.example.com'],
])
->assertUnprocessable()
->assertInvalid(['domains.0']);
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('enables application ingress with explicit domains and port', function () {
createSharedUserAndTeamTables();
@@ -3227,7 +3436,7 @@ it('enables application ingress with explicit domains and port', function () {
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'app.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
)
->andReturn('Caddy ingress applied.');
@@ -3251,6 +3460,66 @@ it('enables application ingress with explicit domains and port', function () {
->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']);
});
it('returns flux error details when application ingress sync fails', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$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' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'ingress'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$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-test',
'status' => 'running',
'mesh_namespace' => 'default',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->andThrow(new RuntimeException('start Caddy ingress: podman exited with status 125'));
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/applications/{$application->id}/ingress", [
'ingress_enabled' => true,
'internal_port' => 3000,
'domains' => ['app.example.com'],
])
->assertStatus(502)
->assertJsonPath('message', 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.')
->assertJsonPath('detail', 'start Caddy ingress: podman exited with status 125');
});
it('syncs caddy ingress routes through flux when enabling ingress on an installed server', function () {
createSharedUserAndTeamTables();
@@ -3309,8 +3578,8 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
'100.64.0.10',
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
Mockery::on(fn (array $apps): bool => count($apps) === 1
&& str_contains($apps[0]['caddyfile'], 'nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'www.nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'http://www.nginx.example.com {')
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
)
->andReturn('Caddy ingress applied.');
@@ -3324,11 +3593,82 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.ingressEnabled', true);
->assertJsonPath('cluster.servers.0.ingressEnabled', true)
->assertJsonPath('cluster.servers.0.ingressType', 'caddy');
expect($server->refresh()->caddy_ingress_status)->toBe('running');
expect($server->refresh()->ingress_type)->toBe('caddy')
->and($server->ingress_status)->toBe('running');
});
it('returns flux error details when server ingress activation fails', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
$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' => 'edge-01',
'host' => '203.0.113.20',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'wireguard_management_ip' => '100.64.0.10',
'last_bootstrapped_at' => now(),
]);
$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-test',
'status' => 'running',
'mesh_namespace' => 'default',
'ingress_enabled' => true,
'internal_port' => 8080,
]);
V5ApplicationDomain::query()->create([
'application_id' => $application->id,
'domain' => 'nginx.example.com',
]);
$fluxClient = Mockery::mock(FluxClient::class);
$fluxClient
->shouldReceive('applyCaddyIngress')
->once()
->andThrow(new RuntimeException('validate Caddyfile: unrecognized directive'));
app()->instance(FluxClient::class, $fluxClient);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
'ingress_enabled' => true,
'ingress_type' => 'caddy',
])
->assertStatus(502)
->assertJsonPath('message', 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.')
->assertJsonPath('detail', 'validate Caddyfile: unrecognized directive');
});
it('keeps editable v5 server builder capacity when disabling builder', function () {
@@ -3735,7 +4075,7 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain('function csrfToken()')
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain("import { Button } from '@/components/ui/button';")
->toContain("import { Button } from '@/components/ui/button';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
@@ -4517,7 +4857,8 @@ function createSharedUserAndTeamTables(): void
$table->string('ssh_user');
$table->unsignedInteger('ssh_port');
$table->string('status')->default('installed');
$table->string('caddy_ingress_status')->nullable();
$table->string('ingress_type')->nullable();
$table->string('ingress_status')->nullable();
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
@@ -8,6 +8,7 @@ use App\Models\V5\ApplicationDomain;
use App\Models\V5\Server;
use App\Services\Flux\FluxClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
uses(TestCase::class);
@@ -34,15 +35,16 @@ it('generates a caddy ingress compose file with health endpoint and application
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
->and($configuration['compose'])->toContain('80:80')
->and($configuration['compose'])->toContain('443:443')
->and($configuration['compose'])->not->toContain('443:443')
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
->and($configuration['compose'])->toContain('./apps:/etc/caddy/apps:ro')
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
->and($configuration['caddyfile'])->toContain('respond 404')
->and($configuration['caddyfile'])->toContain('import apps/*.caddy')
->and($configuration['apps'])->toHaveCount(1)
->and($configuration['apps'][0]['caddyfile'])->toContain('nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->toContain('http://www.nginx.example.com {')
->and($configuration['apps'][0]['caddyfile'])->not->toContain('https://')
->and($configuration['apps'][0]['caddyfile'])->toContain('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080');
});
@@ -152,3 +154,89 @@ it('stops caddy ingress through flux instead of ssh', function () {
expect($result)->toBe('Caddy ingress stopped.');
});
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.');
}
$body = json_encode([
'request_id' => 'test-request',
'status' => 'error',
'code' => 500,
'message' => 'start Caddy ingress: podman exited with status 125',
], JSON_THROW_ON_ERROR);
withFakeFluxSocket(
"HTTP/1.1 500 Internal Server Error\r\n".
"Content-Type: application/json\r\n".
'Content-Length: '.strlen($body)."\r\n".
"\r\n".
$body,
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
it('uses a friendly message when flux returns an invalid http response', function () {
if (! function_exists('pcntl_fork')) {
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
}
withFakeFluxSocket(
'',
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
);
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
it('uses separate flux timeouts for health checks and command dispatches', function () {
expect(config('flux.health_timeout_seconds'))->toBe(1.0)
->and(config('flux.connection_timeout_seconds'))->toBe(1.0)
->and(config('flux.dispatch_timeout_seconds'))->toBe(35.0);
});
function withFakeFluxSocket(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';
$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) {
$connection = stream_socket_accept($server, 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($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);
}
}