From 1c091e9e36c211a87f581ac71861004ee5551e7b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:10:48 +0200 Subject: [PATCH] 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. --- .../V5/Flux/ApplyFluxResourceStatusUpdate.php | 3 +- .../GenerateCaddyIngressConfiguration.php | 4 +- app/Actions/V5/Proxy/StartCaddyIngress.php | 5 +- app/Actions/V5/Proxy/StopCaddyIngress.php | 2 +- app/Events/V5CanvasResourceUpdated.php | 3 +- .../Controllers/V5/DashboardController.php | 81 +++- app/Models/V5/Server.php | 20 +- app/Services/Flux/FluxClient.php | 49 +- config/flux.php | 2 + ...6_06_16_130650_v5_create_servers_table.php | 4 + ...19_140000_v5_create_applications_table.php | 12 + ...dd_canvas_position_to_v5_servers_table.php | 29 -- ...ddy_ingress_status_to_v5_servers_table.php | 38 -- ..._ingress_routing_to_applications_table.php | 40 -- database/schema/testing-schema.sql | 39 +- resources/js/v5/Pages/Clusters.tsx | 248 +++++++--- resources/js/v5/Pages/Dashboard.tsx | 235 ++++++++-- resources/js/v5/components/ui/tooltip.tsx | 64 +++ resources/js/v5/types.ts | 3 + .../V5/ClusterCapabilitiesSummaryTest.php | 7 + tests/Feature/V5/DashboardTest.php | 425 ++++++++++++++++-- .../Unit/V5/CaddyIngressConfigurationTest.php | 94 +++- 22 files changed, 1120 insertions(+), 287 deletions(-) delete mode 100644 database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php delete mode 100644 database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php delete mode 100644 database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php create mode 100644 resources/js/v5/components/ui/tooltip.tsx diff --git a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php index d3a078d8a..f169145e3 100644 --- a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php +++ b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php @@ -136,7 +136,8 @@ class ApplyFluxResourceStatusUpdate } $server->update([ - 'caddy_ingress_status' => $status, + 'ingress_type' => 'caddy', + 'ingress_status' => $status, 'last_status_check' => 'flux', 'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'), 'last_status_checked_at' => now(), diff --git a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php index c78f05fbc..aeb3768db 100644 --- a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php +++ b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php @@ -35,8 +35,6 @@ class GenerateCaddyIngressConfiguration 'restart' => 'unless-stopped', 'ports' => [ '80:80', - '443:443', - '443:443/udp', ], 'volumes' => [ './Caddyfile:/etc/caddy/Caddyfile:ro', @@ -102,7 +100,7 @@ CADDY; $upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$application->internal_port}"; return implode("\n", [ - "{$domain->domain} {", + "http://{$domain->domain} {", " reverse_proxy {$upstream}", '}', ]); diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php index cf16e204f..d11f089c1 100644 --- a/app/Actions/V5/Proxy/StartCaddyIngress.php +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -30,7 +30,10 @@ class StartCaddyIngress $output = $this->fluxClient->applyCaddyIngress($hostId, $configuration['caddyfile'], $configuration['apps']); if ($server->exists) { - $server->update(['caddy_ingress_status' => 'running']); + $server->update([ + 'ingress_type' => 'caddy', + 'ingress_status' => 'running', + ]); } return $output; diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php index 6f6b64c42..12ca01421 100644 --- a/app/Actions/V5/Proxy/StopCaddyIngress.php +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -23,7 +23,7 @@ class StopCaddyIngress $output = $this->fluxClient->stopCaddyIngress($hostId); if ($server->exists) { - $server->update(['caddy_ingress_status' => 'exited']); + $server->update(['ingress_status' => 'exited']); } return $output; diff --git a/app/Events/V5CanvasResourceUpdated.php b/app/Events/V5CanvasResourceUpdated.php index bb6cce17e..b74e2aed4 100644 --- a/app/Events/V5CanvasResourceUpdated.php +++ b/app/Events/V5CanvasResourceUpdated.php @@ -82,7 +82,8 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow 'id' => (string) $server->id, 'name' => $server->name, 'host' => $server->host, - 'status' => $server->caddyIngressStatus(), + 'type' => $server->ingressType(), + 'status' => $server->ingressStatus(), 'canvasX' => $server->canvas_x ?? -352, 'canvasY' => $server->canvas_y ?? 0, ]; diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index c781339a6..b5ec28722 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -19,6 +19,7 @@ use App\Models\V5\ApplicationDomain as V5ApplicationDomain; use App\Models\V5\Cluster as V5Cluster; use App\Models\V5\ResourceConnection; use App\Models\V5\Server as V5Server; +use App\Rules\ValidHostname; use App\Services\Flux\FluxClient; use App\Services\Flux\FluxHealth; use Illuminate\Database\Eloquent\Builder; @@ -336,7 +337,8 @@ class DashboardController extends Controller : 'exited'; $server->update([ - 'caddy_ingress_status' => $state, + 'ingress_type' => 'caddy', + 'ingress_status' => $state, 'last_status_check' => 'flux', 'last_status_output' => 'Caddy ingress state refreshed from coold.', 'last_status_checked_at' => now(), @@ -389,10 +391,18 @@ class DashboardController extends Controller $validated = $request->validate([ 'ingress_enabled' => ['required', 'boolean'], 'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'], - 'domains' => ['sometimes', 'array'], - 'domains.*' => ['required', 'string', 'max:255', 'distinct'], + 'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'], + 'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname], ]); + $application->loadMissing('server'); + + if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) { + return response()->json([ + 'message' => 'Enable ingress on the server before enabling app ingress.', + ], 422); + } + DB::transaction(function () use ($application, $validated): void { $application->update([ 'ingress_enabled' => $validated['ingress_enabled'], @@ -416,7 +426,11 @@ class DashboardController extends Controller $application->refresh()->load(['server', 'domains']); if ($application->server?->isIngress() && $application->server->status === 'installed') { - StartCaddyIngress::run($application->server); + try { + StartCaddyIngress::run($application->server); + } catch (\RuntimeException $exception) { + return $this->ingressSyncErrorResponse($exception); + } } return response()->json([ @@ -742,10 +756,17 @@ class DashboardController extends Controller 'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'], 'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'], 'ingress_enabled' => ['sometimes', 'boolean'], + 'ingress_type' => [ + Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), + 'nullable', + 'string', + Rule::in(['caddy']), + ], ]); $builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled); $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false); + $ingressType = $ingressEnabled ? $validated['ingress_type'] : null; $builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity); $builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota; $devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']); @@ -760,6 +781,7 @@ class DashboardController extends Controller 'ssh_port' => $validated['ssh_port'], 'private_key_id' => $validated['private_key_id'] ?? null, 'status' => 'added', + 'ingress_type' => $ingressType, 'capabilities' => $this->serverCapabilities($builderEnabled, $ingressEnabled), 'builder_enabled' => $builderEnabled, 'builder_capacity' => $builderCapacity, @@ -800,22 +822,35 @@ class DashboardController extends Controller ), 'builder_cpu_quota' => ['required', 'string', 'max:32'], 'ingress_enabled' => ['sometimes', 'boolean'], + 'ingress_type' => [ + Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), + 'nullable', + 'string', + Rule::in(['caddy']), + ], ]); $wasIngress = $server->isIngress(); $builderEnabled = (bool) $validated['builder_enabled']; $ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress); + $ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null; $capabilities = $this->serverCapabilities($builderEnabled, $ingressEnabled); $server->update([ 'capabilities' => $capabilities, + 'ingress_type' => $ingressType, 'builder_enabled' => $builderEnabled, 'builder_capacity' => (int) $validated['builder_capacity'], 'builder_cpu_quota' => $validated['builder_cpu_quota'], ]); $server->refresh(); - $this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled); + + try { + $this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled); + } catch (\RuntimeException $exception) { + return $this->ingressSyncErrorResponse($exception); + } $cluster->load(['servers' => fn ($query) => $query ->with('privateKey') @@ -1273,7 +1308,8 @@ class DashboardController extends Controller 'id' => (string) $server->id, 'name' => $server->name, 'host' => $server->host, - 'status' => $server->caddyIngressStatus(), + 'type' => $server->ingressType(), + 'status' => $server->ingressStatus(), 'canvasX' => $server->canvas_x ?? -self::CANVAS_CARD_WIDTH - self::CANVAS_CARD_GAP, 'canvasY' => $server->canvas_y ?? $index * (self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP), ]; @@ -1424,6 +1460,7 @@ class DashboardController extends Controller 'statusMessage' => $application->status_message, 'runtimeContainerId' => $application->runtime_container_id, 'serverName' => $application->server?->name, + 'serverIngressEnabled' => (bool) $application->server?->isIngress(), 'meshNamespace' => $application->mesh_namespace, 'ingressEnabled' => $application->ingress_enabled, 'internalPort' => $application->internal_port, @@ -1506,6 +1543,37 @@ class DashboardController extends Controller } } + private function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse + { + return response()->json([ + 'message' => $this->friendlyIngressSyncError($exception->getMessage()), + 'detail' => $exception->getMessage(), + ], 502); + } + + private function friendlyIngressSyncError(string $message): string + { + $normalized = Str::lower($message); + + if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) { + return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.'; + } + + if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) { + return 'coold did not respond in time. Check that the server agent is running and connected to Flux.'; + } + + if (str_contains($normalized, 'validate caddyfile')) { + return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.'; + } + + if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) { + return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.'; + } + + return 'Could not update ingress. Check Flux and coold logs, then try again.'; + } + /** * @return array */ @@ -1546,6 +1614,7 @@ class DashboardController extends Controller 'builderCapacity' => $server->builder_capacity, 'builderCpuQuota' => $server->builder_cpu_quota, 'ingressEnabled' => $server->isIngress(), + 'ingressType' => $server->ingress_type, 'uuid' => $server->uuid, 'nodeAddress' => $server->node_address, 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index 0e9148aba..bec89bedd 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -24,7 +24,8 @@ class Server extends V5Model 'ssh_user', 'ssh_port', 'status', - 'caddy_ingress_status', + 'ingress_type', + 'ingress_status', 'capabilities', 'builder_enabled', 'builder_capacity', @@ -50,7 +51,11 @@ class Server extends V5Model protected static function booted(): void { static::updated(function (self $server): void { - if (! $server->wasChanged('status') && ! $server->wasChanged('caddy_ingress_status')) { + if ( + ! $server->wasChanged('status') + && ! $server->wasChanged('ingress_type') + && ! $server->wasChanged('ingress_status') + ) { return; } @@ -111,15 +116,20 @@ class Server extends V5Model return $this->hasCapability('ingress'); } - public function caddyIngressStatus(): string + public function ingressStatus(): string { - if ($this->caddy_ingress_status !== null) { - return $this->caddy_ingress_status; + if ($this->ingress_status !== null) { + return $this->ingress_status; } return $this->status === 'installed' ? 'running' : 'unknown'; } + public function ingressType(): string + { + return $this->ingress_type ?? 'caddy'; + } + public function cluster(): BelongsTo { return $this->belongsTo(Cluster::class); diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index 0a5ff9d64..35dc18062 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -66,14 +66,15 @@ class FluxClient 'request_id' => (string) Str::uuid(), 'command' => $command, ], JSON_THROW_ON_ERROR); - $timeout = (float) config('flux.health_timeout_seconds', 1.0); - $stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout); + $connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0); + $dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0); + $stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout); if ($stream === false) { throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode})."); } - stream_set_timeout($stream, (int) ceil($timeout)); + stream_set_timeout($stream, (int) ceil($dispatchTimeout)); fwrite($stream, implode("\r\n", [ 'POST /v1/coold/dispatch HTTP/1.1', @@ -89,12 +90,13 @@ class FluxClient $response = stream_get_contents($stream) ?: ''; fclose($stream); - if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) { - throw new RuntimeException('Flux dispatch did not return HTTP 200.'); - } + $statusCode = $this->statusCode($response); + $responseBody = $this->responseBody($response); + $payload = $responseBody === '' ? null : json_decode($responseBody, true); - $responseBody = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : ''; - $payload = json_decode($responseBody, true); + if ($statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException($this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}."); + } if (! is_array($payload)) { throw new RuntimeException('Flux dispatch returned an invalid response.'); @@ -109,6 +111,37 @@ class FluxClient return $payload; } + private function statusCode(string $response): int + { + if ($response === '') { + throw new RuntimeException('Flux did not return a response before the timeout. Check that coold is connected to Flux and try again.'); + } + + if (preg_match('/^HTTP\/\d(?:\.\d)?\s+(\d{3})/', $response, $matches) !== 1) { + throw new RuntimeException('Could not talk to Flux. Check that Flux is running in the Coolify container.'); + } + + return (int) $matches[1]; + } + + private function responseBody(string $response): string + { + $position = strpos($response, "\r\n\r\n"); + + return $position === false ? '' : substr($response, $position + 4); + } + + private function errorMessage(mixed $payload, string $responseBody): ?string + { + if (is_array($payload) && is_string($payload['message'] ?? null) && $payload['message'] !== '') { + return $payload['message']; + } + + $message = trim($responseBody); + + return $message === '' ? null : Str::limit($message, 1000); + } + /** * @param array $payload */ diff --git a/config/flux.php b/config/flux.php index cf23d57dc..955559edd 100644 --- a/config/flux.php +++ b/config/flux.php @@ -5,5 +5,7 @@ return [ 'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')), 'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')), 'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0), + 'connection_timeout_seconds' => (float) env('COOLIFY_FLUX_CONNECTION_TIMEOUT_SECONDS', 1.0), + 'dispatch_timeout_seconds' => (float) env('COOLIFY_FLUX_DISPATCH_TIMEOUT_SECONDS', 35.0), 'laravel_api_token' => env('COOLIFY_FLUX_LARAVEL_API_TOKEN'), ]; diff --git a/database/migrations/2026_06_16_130650_v5_create_servers_table.php b/database/migrations/2026_06_16_130650_v5_create_servers_table.php index 6e3579b3b..68a36deac 100644 --- a/database/migrations/2026_06_16_130650_v5_create_servers_table.php +++ b/database/migrations/2026_06_16_130650_v5_create_servers_table.php @@ -23,6 +23,8 @@ return new class extends Migration $table->string('ssh_user'); $table->unsignedInteger('ssh_port')->default(22); $table->string('status')->default('installed'); + $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); @@ -33,6 +35,8 @@ return new class extends Migration $table->string('wireguard_management_ip')->nullable(); $table->string('wireguard_public_key')->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(); diff --git a/database/migrations/2026_06_19_140000_v5_create_applications_table.php b/database/migrations/2026_06_19_140000_v5_create_applications_table.php index e478f3c1d..c89aa1d33 100644 --- a/database/migrations/2026_06_19_140000_v5_create_applications_table.php +++ b/database/migrations/2026_06_19_140000_v5_create_applications_table.php @@ -25,6 +25,8 @@ return new class extends Migration $table->text('status_message')->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(); @@ -33,6 +35,15 @@ return new class extends Migration $table->index(['team_id', 'project_id', 'environment_id']); $table->index(['team_id', 'server_id']); }); + + Schema::create('v5_application_domains', function (Blueprint $table) { + $table->id(); + $table->foreignId('application_id')->constrained('v5_applications')->cascadeOnDelete(); + $table->string('domain'); + $table->timestamps(); + + $table->unique(['application_id', 'domain']); + }); } /** @@ -40,6 +51,7 @@ return new class extends Migration */ public function down(): void { + Schema::dropIfExists('v5_application_domains'); Schema::dropIfExists('v5_applications'); } }; diff --git a/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php b/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php deleted file mode 100644 index 3ccb15c94..000000000 --- a/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php +++ /dev/null @@ -1,29 +0,0 @@ -integer('canvas_x')->nullable()->after('container_subnets'); - $table->integer('canvas_y')->nullable()->after('canvas_x'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('v5_servers', function (Blueprint $table) { - $table->dropColumn(['canvas_x', 'canvas_y']); - }); - } -}; diff --git a/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php b/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php deleted file mode 100644 index ce87055ad..000000000 --- a/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php +++ /dev/null @@ -1,38 +0,0 @@ -string('caddy_ingress_status')->nullable()->after('status'); - }); - - DB::table('v5_servers') - ->where('status', 'installed') - ->where(function ($query) { - $query - ->where('capabilities', 'like', '%"ingress"%') - ->orWhere('capabilities', 'like', '%ingress%'); - }) - ->update(['caddy_ingress_status' => 'running']); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('v5_servers', function (Blueprint $table) { - $table->dropColumn('caddy_ingress_status'); - }); - } -}; diff --git a/database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php b/database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php deleted file mode 100644 index 0b8c45176..000000000 --- a/database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php +++ /dev/null @@ -1,40 +0,0 @@ -boolean('ingress_enabled')->default(false); - $table->unsignedSmallInteger('internal_port')->nullable(); - }); - - Schema::create('v5_application_domains', function (Blueprint $table) { - $table->id(); - $table->foreignId('application_id')->constrained('v5_applications')->cascadeOnDelete(); - $table->string('domain'); - $table->timestamps(); - - $table->unique(['application_id', 'domain']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('v5_application_domains'); - - Schema::table('v5_applications', function (Blueprint $table) { - $table->dropColumn(['ingress_enabled', 'internal_port']); - }); - } -}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index 63c326e1f..8d26f365e 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -1363,7 +1363,8 @@ CREATE TABLE IF NOT EXISTS "v5_servers" ( "ssh_user" TEXT NOT NULL, "ssh_port" INTEGER DEFAULT '22' NOT NULL, "status" TEXT DEFAULT 'installed' NOT NULL, - "caddy_ingress_status" TEXT, + "ingress_type" TEXT, + "ingress_status" TEXT, "capabilities" TEXT, "builder_enabled" INTEGER DEFAULT false NOT NULL, "builder_capacity" INTEGER DEFAULT '0' NOT NULL, @@ -1432,6 +1433,34 @@ CREATE TABLE IF NOT EXISTS "v5_application_domains" ( "updated_at" TEXT ); +CREATE TABLE IF NOT EXISTS "v5_resource_connections" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "project_id" INTEGER NOT NULL, + "environment_id" INTEGER NOT NULL, + "resource_one_type" TEXT NOT NULL, + "resource_one_id" INTEGER NOT NULL, + "resource_two_type" TEXT NOT NULL, + "resource_two_id" INTEGER NOT NULL, + "resource_pair_key" TEXT NOT NULL, + "created_by_user_id" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "connection_id" INTEGER NOT NULL, + "source_resource_type" TEXT NOT NULL, + "source_resource_id" INTEGER NOT NULL, + "target_resource_type" TEXT NOT NULL, + "target_resource_id" INTEGER NOT NULL, + "protocol" TEXT DEFAULT 'tcp' NOT NULL, + "port" INTEGER NOT NULL, + "created_at" TEXT, + "updated_at" TEXT +); + CREATE TABLE IF NOT EXISTS "webhook_notification_settings" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "team_id" INTEGER NOT NULL, @@ -1548,6 +1577,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_uniq CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); CREATE UNIQUE INDEX IF NOT EXISTS "v5_applications_container_name_unique" ON "v5_applications" (container_name); CREATE UNIQUE INDEX IF NOT EXISTS "v5_application_domains_application_id_domain_unique" ON "v5_application_domains" (application_id, domain); +CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connections_team_id_resource_pair_key_unique" ON "v5_resource_connections" (team_id, resource_pair_key); +CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connection_rules_unique_direction_port" ON "v5_resource_connection_rules" (connection_id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, protocol, port); CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid); CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); @@ -1869,7 +1900,5 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_0 INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318); -INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_141231_add_canvas_position_to_v5_servers_table', 319); -INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table', 320); -INSERT INTO "migrations" ("id", "migration", "batch") VALUES (321, '2026_06_19_182231_create_container_statuses_table', 321); -INSERT INTO "migrations" ("id", "migration", "batch") VALUES (322, '2026_06_20_072818_v5_add_ingress_routing_to_applications_table', 322); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_142000_v5_create_resource_connections_table', 319); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_182231_create_container_statuses_table', 320); diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index a7363b7a6..57150f9ca 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -64,6 +64,7 @@ type ServerFormErrors = { builder_enabled?: string[]; builder_capacity?: string[]; builder_cpu_quota?: string[]; + ingress_type?: string[]; wireguard_listen_port_override?: string[]; wireguard_endpoint_override?: string[]; }; @@ -139,6 +140,13 @@ const clusterDefaults = { builderTimeoutSecs: '1800', }; +const ingressTypes = [ + { + label: 'Caddy', + value: 'caddy', + }, +]; + function formatDate(value: string | null): string { if (value === null) { return 'Never'; @@ -188,6 +196,7 @@ export default function Clusters({ const [serverNodeAddress, setServerNodeAddress] = useState(''); const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true); const [serverIngressEnabled, setServerIngressEnabled] = useState(false); + const [serverIngressType, setServerIngressType] = useState('caddy'); const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2'); const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota); const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState(''); @@ -196,6 +205,7 @@ export default function Clusters({ const [editingServer, setEditingServer] = useState(null); const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true); const [editServerIngressEnabled, setEditServerIngressEnabled] = useState(false); + const [editServerIngressType, setEditServerIngressType] = useState('caddy'); const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2'); const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota); const [editServerErrors, setEditServerErrors] = useState({}); @@ -422,6 +432,7 @@ export default function Clusters({ node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress, builder_enabled: serverBuilderEnabled, ingress_enabled: serverIngressEnabled, + ingress_type: serverIngressEnabled ? serverIngressType : null, builder_capacity: Number(serverBuilderCapacity), builder_cpu_quota: serverBuilderCpuQuota, wireguard_listen_port_override: @@ -480,6 +491,7 @@ export default function Clusters({ body: JSON.stringify({ builder_enabled: editServerBuilderEnabled, ingress_enabled: editServerIngressEnabled, + ingress_type: editServerIngressEnabled ? editServerIngressType : null, builder_capacity: Number(editServerBuilderCapacity), builder_cpu_quota: editServerBuilderCpuQuota, }), @@ -496,8 +508,10 @@ export default function Clusters({ } if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + setEditServerErrors({ - builder_capacity: ['Unable to update this server. Please try again.'], + builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'], }); setIsServerUpdateSubmitting(false); @@ -629,6 +643,7 @@ export default function Clusters({ setEditingServer(server); setEditServerBuilderEnabled(server.builderEnabled); setEditServerIngressEnabled(server.ingressEnabled); + setEditServerIngressType(server.ingressType ?? 'caddy'); setEditServerBuilderCapacity(String(server.builderCapacity)); setEditServerBuilderCpuQuota(server.builderCpuQuota); setEditServerErrors({}); @@ -721,6 +736,8 @@ export default function Clusters({ setSelectedPrivateKeyId(''); setServerNodeAddress(''); setServerBuilderEnabled(selectedCluster?.builderEnabled ?? true); + setServerIngressEnabled(false); + setServerIngressType('caddy'); setServerBuilderCapacity(String(selectedCluster?.builderCapacity ?? 2)); setServerBuilderCpuQuota(selectedCluster?.builderCpuQuota ?? clusterDefaults.builderCpuQuota); setWireguardListenPortOverride(''); @@ -733,6 +750,7 @@ export default function Clusters({ setEditingServer(null); setEditServerBuilderEnabled(true); setEditServerIngressEnabled(false); + setEditServerIngressType('caddy'); setEditServerBuilderCapacity('2'); setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota); setEditServerErrors({}); @@ -1586,53 +1604,104 @@ export default function Clusters({ - - Builder capacity - - setServerBuilderCapacity(event.target.value) - } - inputMode="numeric" - aria-invalid={serverErrors.builder_capacity ? true : undefined} - /> - - +
+ + + setServerBuilderEnabled(event.target.checked) + } + /> + Enable builder on this server + - - Builder CPU quota - - setServerBuilderCpuQuota(event.target.value) - } - placeholder="200%" - aria-invalid={serverErrors.builder_cpu_quota ? true : undefined} - /> - - +
+ + Builder capacity + + setServerBuilderCapacity(event.target.value) + } + inputMode="numeric" + aria-invalid={ + serverErrors.builder_capacity ? true : undefined + } + /> + + - - - setServerBuilderEnabled(event.target.checked) - } - /> - Enable builder on this server - + + Builder CPU quota + + setServerBuilderCpuQuota(event.target.value) + } + placeholder="200%" + aria-invalid={ + serverErrors.builder_cpu_quota ? true : undefined + } + /> + + +
+
- - - setServerIngressEnabled(event.target.checked) - } - /> - Enable Caddy ingress on this server - +
+ + + setServerIngressEnabled(event.target.checked) + } + /> + Enable ingress on this server + + + + Ingress type + + + +
WireGuard listen override @@ -1701,13 +1770,13 @@ export default function Clusters({ Edit server - Update builder scheduling limits and Caddy ingress for {editingServer?.name ?? 'this server'}. + Update builder scheduling limits and ingress for {editingServer?.name ?? 'this server'}. Networking and bootstrap settings stay locked after creation.
-
+
Enable builder on this server +
+ + Builder capacity + setEditServerBuilderCapacity(event.target.value)} + inputMode="numeric" + aria-invalid={editServerErrors.builder_capacity ? true : undefined} + /> + + + + + Builder CPU quota + setEditServerBuilderCpuQuota(event.target.value)} + placeholder="200%" + aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined} + /> + + +
+
+ +
setEditServerIngressEnabled(event.target.checked)} /> - Enable Caddy ingress on this server - -
- -
- - Builder capacity - setEditServerBuilderCapacity(event.target.value)} - inputMode="numeric" - aria-invalid={editServerErrors.builder_capacity ? true : undefined} - /> - + Enable ingress on this server - - Builder CPU quota - setEditServerBuilderCpuQuota(event.target.value)} - placeholder="200%" - aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined} - /> - + + Ingress type + + -
+

Host, bootstrap credentials, node address, and WireGuard overrides are not diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index 1e46d60c1..6fc9862fe 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -2,6 +2,11 @@ import { Head } from '@inertiajs/react'; import { useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, type WheelEvent } from 'react'; import { AppNavbar } from '@/components/app-navbar'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Field, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision'; import { csrfToken } from '@/lib/csrf'; import { cn } from '@/lib/utils'; @@ -25,6 +30,13 @@ type V5CanvasResourceUpdatedEvent = { caddyIngress: V5CaddyIngress | null; }; +type IngressModalState = { + application: V5Application; + domains: string; + internalPort: string; + error: string | null; +}; + type EchoChannel = { listen: (event: string, callback: (payload: unknown) => void) => EchoChannel; subscribed?: (callback: () => void) => EchoChannel; @@ -148,6 +160,9 @@ export default function Dashboard({ const [selectedNginxServerId, setSelectedNginxServerId] = useState(nginxServers[0]?.id ?? ''); const [isRefreshing, setIsRefreshing] = useState(false); const [notice, setNotice] = useState(null); + const [ingressModal, setIngressModal] = useState(null); + const [isSavingIngress, setIsSavingIngress] = useState(false); + const [savingIngressApplicationId, setSavingIngressApplicationId] = useState(null); const canvasRef = useRef(null); const hasCanvasNodes = applications.length > 0 || ingresses.length > 0; @@ -649,34 +664,69 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection } } - async function updateApplicationIngress(application: V5Application, enabled: boolean): Promise { + function openApplicationIngressModal(application: V5Application): void { setNotice(null); - const domains = enabled - ? window - .prompt('Domains for this app, separated by commas', application.domains.join(', ')) - ?.split(',') - .map((domain) => domain.trim()) - .filter(Boolean) - : application.domains; - - if (enabled && (!domains || domains.length === 0)) { - setNotice('Add at least one domain before enabling app ingress.'); + if (!application.serverIngressEnabled) { + setNotice('Enable ingress on the server before enabling app ingress.'); return; } - const internalPort = enabled - ? Number(window.prompt('Internal container port', String(application.internalPort ?? ''))) - : application.internalPort; + setIngressModal({ + application, + domains: application.domains.join(', '), + internalPort: application.internalPort ? String(application.internalPort) : '', + error: null, + }); + } - if (enabled && (!Number.isInteger(internalPort) || Number(internalPort) < 1 || Number(internalPort) > 65535)) { - setNotice('Choose a valid internal port before enabling app ingress.'); + async function disableApplicationIngress(application: V5Application): Promise { + await saveApplicationIngress(application, false, application.domains, application.internalPort); + } + + async function submitApplicationIngress(): Promise { + if (!ingressModal) { + return; + } + + const domains = ingressModal.domains + .split(',') + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); + const internalPort = Number(ingressModal.internalPort); + const invalidDomain = domains.find((domain) => !isValidDomain(domain)); + + if (domains.length === 0) { + setIngressModal({ ...ingressModal, error: 'Add at least one valid domain.' }); return; } - const selectedInternalPort = enabled ? Number(internalPort) : application.internalPort; + if (invalidDomain) { + setIngressModal({ ...ingressModal, error: `${invalidDomain} is not a valid domain.` }); + + return; + } + + if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) { + setIngressModal({ ...ingressModal, error: 'Choose a valid internal port between 1 and 65535.' }); + + return; + } + + await saveApplicationIngress(ingressModal.application, true, [...new Set(domains)], internalPort); + } + + async function saveApplicationIngress( + application: V5Application, + enabled: boolean, + domains: string[], + internalPort: number | null, + ): Promise { + setNotice(null); + setIsSavingIngress(true); + setSavingIngressApplicationId(application.id); try { const response = await fetch(`/v5/applications/${application.id}/ingress`, { @@ -689,13 +739,20 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection }, body: JSON.stringify({ ingress_enabled: enabled, - internal_port: selectedInternalPort, + internal_port: internalPort, domains, }), }); if (!response.ok) { - setNotice('Could not update application ingress.'); + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + const message = payload?.message ?? 'Could not update application ingress.'; + + if (ingressModal) { + setIngressModal({ ...ingressModal, error: message }); + } else { + setNotice(message); + } return; } @@ -706,11 +763,63 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection candidate.id === payload.application.id ? payload.application : candidate, ), ); + setIngressModal(null); } catch (error) { - setNotice(error instanceof Error ? error.message : 'Could not update application ingress.'); + const message = error instanceof Error ? error.message : 'Could not update application ingress.'; + + if (ingressModal) { + setIngressModal({ ...ingressModal, error: message }); + } else { + setNotice(message); + } + } finally { + setIsSavingIngress(false); + setSavingIngressApplicationId(null); } } + function isValidDomain(domain: string): boolean { + if (domain.length < 1 || domain.length > 253 || domain.startsWith('.') || domain.endsWith('.')) { + return false; + } + + return domain.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)); + } + + function renderIngressButton(application: V5Application) { + const isDisabled = !application.ingressEnabled && !application.serverIngressEnabled; + const isApplicationIngressSaving = savingIngressApplicationId === application.id; + const button = ( + + ); + + if (!isDisabled) { + return button; + } + + return ( + + }>{button} + +

You need to enable ingress in server settings first.

+ + + ); + } + async function addNginx(): Promise { setIsCreating(true); setNotice(null); @@ -1091,7 +1200,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection } return ( - <> +
@@ -1552,17 +1661,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection ? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}` : 'Private'} - + {renderIngressButton(application)}
@@ -1572,6 +1671,76 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection - + + {ingressModal && ( + { + if (!open && !isSavingIngress) { + setIngressModal(null); + } + }} + > + + + Enable app ingress + + Route public domains to {ingressModal.application.name} through the server ingress. + + + + { + event.preventDefault(); + void submitApplicationIngress(); + }} + > + + Domains + + setIngressModal({ ...ingressModal, domains: event.target.value, error: null }) + } + placeholder="example.com, www.example.com" + /> + + Use hostnames only, separated by commas. No scheme, path, wildcard, or port. + + + + + Internal port + + setIngressModal({ ...ingressModal, internalPort: event.target.value, error: null }) + } + placeholder="3000" + /> + + + {ingressModal.error && ( +
+

Ingress update failed

+

{ingressModal.error}

+
+ )} + +
+ +
+ +
+
+ )} +
); } diff --git a/resources/js/v5/components/ui/tooltip.tsx b/resources/js/v5/components/ui/tooltip.tsx new file mode 100644 index 000000000..3ca8031c5 --- /dev/null +++ b/resources/js/v5/components/ui/tooltip.tsx @@ -0,0 +1,64 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/resources/js/v5/types.ts b/resources/js/v5/types.ts index b003f48fc..4b5a3991a 100644 --- a/resources/js/v5/types.ts +++ b/resources/js/v5/types.ts @@ -9,12 +9,14 @@ export type V5Server = { id: string; name: string; host: string; + type: string; status: string; capabilities: string[]; builderEnabled: boolean; builderCapacity: number; builderCpuQuota: string; ingressEnabled: boolean; + ingressType: string | null; uuid: string | null; nodeAddress: string | null; wireguardListenPortOverride: number | null; @@ -90,6 +92,7 @@ export type V5Application = { statusMessage: string | null; runtimeContainerId: string | null; serverName: string | null; + serverIngressEnabled: boolean; meshNamespace: string; ingressEnabled: boolean; internalPort: number | null; diff --git a/tests/Feature/V5/ClusterCapabilitiesSummaryTest.php b/tests/Feature/V5/ClusterCapabilitiesSummaryTest.php index 943368101..98e7f7f56 100644 --- a/tests/Feature/V5/ClusterCapabilitiesSummaryTest.php +++ b/tests/Feature/V5/ClusterCapabilitiesSummaryTest.php @@ -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.'"); +}); diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index d599b480c..9c8726d53 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -120,6 +120,94 @@ it('does not render v5 application status messages on dashboard cards', function ->not->toContain('{application.statusMessage}

'); }); +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('toContain('open') + ->toContain('toContain('showCloseButton') + ->toContain('') + ->not->toContain('>Close'); +}); + +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('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); diff --git a/tests/Unit/V5/CaddyIngressConfigurationTest.php b/tests/Unit/V5/CaddyIngressConfigurationTest.php index a5cb708a1..581454556 100644 --- a/tests/Unit/V5/CaddyIngressConfigurationTest.php +++ b/tests/Unit/V5/CaddyIngressConfigurationTest.php @@ -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); + } +}