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

@@ -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(),
@@ -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}",
'}',
]);
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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,
];
@@ -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<string, mixed>
*/
@@ -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,
+15 -5
View File
@@ -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);
+41 -8
View File
@@ -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<string, mixed> $payload
*/