mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 10:14:47 +00:00
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:
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}",
|
||||
'}',
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user