From ad0bcc8a4a19a73dd3aec1b6bcbf8bb19d2f4350 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:08:45 +0200 Subject: [PATCH] feat(v5): add application ingress routing --- .../GenerateCaddyIngressConfiguration.php | 92 ++++-- app/Actions/V5/Proxy/StartCaddyIngress.php | 82 ++--- app/Actions/V5/Proxy/StopCaddyIngress.php | 65 +--- .../Controllers/V5/DashboardController.php | 54 +++- app/Models/V5/Application.php | 11 + app/Models/V5/ApplicationDomain.php | 20 ++ app/Services/Flux/FluxClient.php | 35 +++ ..._ingress_routing_to_applications_table.php | 40 +++ database/schema/testing-schema.sql | 12 + resources/js/v5/Pages/Dashboard.tsx | 89 ++++++ resources/js/v5/types.ts | 3 + routes/v5.php | 1 + scripts/dev.sh | 4 - .../DevScriptFirewallDelegationTest.php | 9 + tests/Feature/V5/DashboardTest.php | 282 +++++++++++++++++- .../Unit/V5/CaddyIngressConfigurationTest.php | 199 ++++++------ 16 files changed, 763 insertions(+), 235 deletions(-) create mode 100644 app/Models/V5/ApplicationDomain.php create mode 100644 database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php diff --git a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php index cb97d4fdf..c78f05fbc 100644 --- a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php +++ b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php @@ -2,6 +2,9 @@ namespace App\Actions\V5\Proxy; +use App\Models\V5\Application; +use App\Models\V5\ApplicationDomain; +use Illuminate\Support\Collection; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -10,11 +13,21 @@ class GenerateCaddyIngressConfiguration use AsAction; /** - * @return array{compose: string, caddyfile: string, commands: array} + * @param Collection|null $applications + * @return array{compose: string, caddyfile: string, apps: array} */ - public function handle(string $basePath = '/data/coolify/v5/ingress/caddy'): array + public function handle(?Collection $applications = null): array { - $compose = Yaml::dump([ + return [ + 'compose' => $this->compose(), + 'caddyfile' => $this->rootCaddyfile(), + 'apps' => $this->appCaddyfiles($applications ?? collect()), + ]; + } + + private function compose(): string + { + return Yaml::dump([ 'services' => [ 'caddy' => [ 'image' => 'docker.io/library/caddy:2-alpine', @@ -27,31 +40,76 @@ class GenerateCaddyIngressConfiguration ], 'volumes' => [ './Caddyfile:/etc/caddy/Caddyfile:ro', + './apps:/etc/caddy/apps:ro', './data:/data', './config:/config', ], ], ], ], 8, 2); + } - $caddyfile = <<<'CADDY' + private function rootCaddyfile(): string + { + return <<<'CADDY' :80 { respond /coolify-health 200 respond 404 } -CADDY; - return [ - 'compose' => $compose, - 'caddyfile' => $caddyfile, - 'commands' => [ - sprintf('if [ "$(id -u)" = "0" ]; then mkdir -p %1$s/data %1$s/config; else sudo mkdir -p %1$s/data %1$s/config; fi', $basePath), - sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/docker-compose.yml > /dev/null; else sudo tee %s/docker-compose.yml > /dev/null; fi", base64_encode($compose), $basePath, $basePath), - sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/Caddyfile > /dev/null; else sudo tee %s/Caddyfile > /dev/null; fi", base64_encode($caddyfile), $basePath, $basePath), - 'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime pull docker.io/library/caddy:2-alpine', - 'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true', - "if command -v podman >/dev/null 2>&1; then runtime=\"sudo podman\"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo \"Neither podman nor docker is installed\" >&2; exit 1; fi; \$runtime run -d --name coolify-v5-caddy --restart unless-stopped -p 80:80 -p 443:443 -p 443:443/udp -v {$basePath}/Caddyfile:/etc/caddy/Caddyfile:ro -v {$basePath}/data:/data -v {$basePath}/config:/config docker.io/library/caddy:2-alpine", - ], - ]; +import apps/*.caddy +CADDY; + } + + /** + * @param Collection $applications + * @return array + */ + private function appCaddyfiles(Collection $applications): array + { + return $applications + ->each(fn (Application $application) => $application->loadMissing('domains')) + ->map(fn (Application $application) => [ + 'name' => $this->appFileName($application), + 'caddyfile' => $this->applicationCaddyfile($application), + ]) + ->filter(fn (array $file) => $file['caddyfile'] !== '') + ->sortBy('name') + ->values() + ->all(); + } + + private function applicationCaddyfile(Application $application): string + { + if (! $application->ingress_enabled || ! $application->internal_port) { + return ''; + } + + return $application->domains + ->map(fn (ApplicationDomain $domain) => $this->applicationRoute($application, $domain)) + ->filter() + ->sort() + ->implode("\n\n"); + } + + private function applicationRoute(Application $application, ApplicationDomain $domain): ?string + { + if ($domain->domain === '') { + return null; + } + + $namespace = $application->mesh_namespace ?: 'default'; + $upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$application->internal_port}"; + + return implode("\n", [ + "{$domain->domain} {", + " reverse_proxy {$upstream}", + '}', + ]); + } + + private function appFileName(Application $application): string + { + return 'app_'.$application->getKey(); } } diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php index aa2d0fd2e..cf16e204f 100644 --- a/app/Actions/V5/Proxy/StartCaddyIngress.php +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -2,84 +2,54 @@ namespace App\Actions\V5\Proxy; -use App\Models\PrivateKey; +use App\Models\V5\Application; use App\Models\V5\Server; -use Illuminate\Support\Facades\Process; +use App\Services\Flux\FluxClient; +use Illuminate\Support\Collection; use Lorisleiva\Actions\Concerns\AsAction; class StartCaddyIngress { use AsAction; + public function __construct(private readonly FluxClient $fluxClient) {} + public function handle(Server $server): string { - $server->loadMissing('privateKey'); - if (! $server->isIngress()) { return 'Server is not an ingress server.'; } - if (! $server->privateKey instanceof PrivateKey) { - return 'No private key is attached to this server.'; + $hostId = $server->wireguard_management_ip ?: $server->node_address; + + if (! is_string($hostId) || $hostId === '') { + return 'Server is missing its Flux host id.'; } - $keyLocation = $this->writeTemporaryPrivateKey($server->privateKey); - - try { - $commands = GenerateCaddyIngressConfiguration::run()['commands']; - $result = Process::timeout(180)->run([ - 'ssh', - '-o', - 'BatchMode=yes', - '-o', - 'LogLevel=ERROR', - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'UserKnownHostsFile=/dev/null', - '-o', - 'ConnectTimeout=10', - '-o', - 'IdentitiesOnly=yes', - '-i', - $keyLocation, - '-p', - (string) $server->ssh_port, - "{$server->ssh_user}@{$server->host}", - implode("\n", $commands), - ]); - - $output = trim($result->output()."\n".$result->errorOutput()); - - if ($result->failed()) { - $server->update(['caddy_ingress_status' => 'failed']); - - throw new \RuntimeException('Failed to start Caddy ingress: '.($output !== '' ? $output : 'No output returned.')); - } + $configuration = GenerateCaddyIngressConfiguration::run($this->applications($server)); + $output = $this->fluxClient->applyCaddyIngress($hostId, $configuration['caddyfile'], $configuration['apps']); + if ($server->exists) { $server->update(['caddy_ingress_status' => 'running']); - - return $output !== '' ? $output : 'Caddy ingress started.'; - } finally { - @unlink($keyLocation); } + + return $output; } - private function writeTemporaryPrivateKey(PrivateKey $privateKey): string + /** + * @return Collection + */ + private function applications(Server $server): Collection { - $keyDirectory = storage_path('app/ssh/keys'); - if (! is_dir($keyDirectory)) { - mkdir($keyDirectory, 0700, true); + if (! $server->exists) { + return collect(); } - $keyLocation = tempnam($keyDirectory, 'v5_caddy_key_'); - if ($keyLocation === false) { - throw new \RuntimeException('Could not create a temporary SSH key file.'); - } - - file_put_contents($keyLocation, $privateKey->private_key); - chmod($keyLocation, 0600); - - return $keyLocation; + return Application::query() + ->where('team_id', $server->team_id) + ->where('server_id', $server->id) + ->with('domains') + ->orderBy('name') + ->get(); } } diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php index 7c09944a0..6f6b64c42 100644 --- a/app/Actions/V5/Proxy/StopCaddyIngress.php +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -2,77 +2,30 @@ namespace App\Actions\V5\Proxy; -use App\Models\PrivateKey; use App\Models\V5\Server; -use Illuminate\Support\Facades\Process; +use App\Services\Flux\FluxClient; use Lorisleiva\Actions\Concerns\AsAction; class StopCaddyIngress { use AsAction; + public function __construct(private readonly FluxClient $fluxClient) {} + public function handle(Server $server): string { - $server->loadMissing('privateKey'); + $hostId = $server->wireguard_management_ip ?: $server->node_address; - if (! $server->privateKey instanceof PrivateKey) { - return 'No private key is attached to this server.'; + if (! is_string($hostId) || $hostId === '') { + return 'Server is missing its Flux host id.'; } - $keyLocation = $this->writeTemporaryPrivateKey($server->privateKey); - - try { - $result = Process::timeout(60)->run([ - 'ssh', - '-o', - 'BatchMode=yes', - '-o', - 'LogLevel=ERROR', - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'UserKnownHostsFile=/dev/null', - '-o', - 'ConnectTimeout=10', - '-o', - 'IdentitiesOnly=yes', - '-i', - $keyLocation, - '-p', - (string) $server->ssh_port, - "{$server->ssh_user}@{$server->host}", - 'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true', - ]); - - $output = trim($result->output()."\n".$result->errorOutput()); - - if ($result->failed()) { - throw new \RuntimeException('Failed to stop Caddy ingress: '.($output !== '' ? $output : 'No output returned.')); - } + $output = $this->fluxClient->stopCaddyIngress($hostId); + if ($server->exists) { $server->update(['caddy_ingress_status' => 'exited']); - - return $output !== '' ? $output : 'Caddy ingress stopped.'; - } finally { - @unlink($keyLocation); - } - } - - private function writeTemporaryPrivateKey(PrivateKey $privateKey): string - { - $keyDirectory = storage_path('app/ssh/keys'); - if (! is_dir($keyDirectory)) { - mkdir($keyDirectory, 0700, true); } - $keyLocation = tempnam($keyDirectory, 'v5_caddy_key_'); - if ($keyLocation === false) { - throw new \RuntimeException('Could not create a temporary SSH key file.'); - } - - file_put_contents($keyLocation, $privateKey->private_key); - chmod($keyLocation, 0600); - - return $keyLocation; + return $output; } } diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index eecb6fc6f..c781339a6 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -15,6 +15,7 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Team; use App\Models\V5\Application as V5Application; +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; @@ -377,6 +378,52 @@ class DashboardController extends Controller ]); } + public function updateApplicationIngress(Request $request, V5Application $application): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) { + abort(404); + } + + $validated = $request->validate([ + 'ingress_enabled' => ['required', 'boolean'], + 'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'domains' => ['sometimes', 'array'], + 'domains.*' => ['required', 'string', 'max:255', 'distinct'], + ]); + + DB::transaction(function () use ($application, $validated): void { + $application->update([ + 'ingress_enabled' => $validated['ingress_enabled'], + 'internal_port' => $validated['internal_port'] ?? null, + ]); + + if (array_key_exists('domains', $validated)) { + $application->domains()->delete(); + + collect($validated['domains']) + ->map(fn (string $domain) => trim($domain)) + ->filter() + ->unique() + ->each(fn (string $domain) => V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => $domain, + ])); + } + }); + + $application->refresh()->load(['server', 'domains']); + + if ($application->server?->isIngress() && $application->server->status === 'installed') { + StartCaddyIngress::run($application->server); + } + + return response()->json([ + 'application' => $this->serializeApplication($application), + ]); + } + public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse { $currentTeam = $request->attributes->get('v5.currentTeam'); @@ -1366,7 +1413,7 @@ class DashboardController extends Controller */ private function serializeApplication(V5Application $application): array { - $application->loadMissing('server'); + $application->loadMissing(['server', 'domains']); return [ 'id' => (string) $application->id, @@ -1378,6 +1425,9 @@ class DashboardController extends Controller 'runtimeContainerId' => $application->runtime_container_id, 'serverName' => $application->server?->name, 'meshNamespace' => $application->mesh_namespace, + 'ingressEnabled' => $application->ingress_enabled, + 'internalPort' => $application->internal_port, + 'domains' => $application->domains->pluck('domain')->values()->all(), 'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal', 'canvasX' => $application->canvas_x, 'canvasY' => $application->canvas_y, @@ -1441,7 +1491,7 @@ class DashboardController extends Controller private function reconcileCaddyIngress(V5Server $server, bool $wasIngress, bool $isIngress): void { - if ($server->status !== 'installed' || ! $server->privateKey instanceof PrivateKey) { + if ($server->status !== 'installed') { return; } diff --git a/app/Models/V5/Application.php b/app/Models/V5/Application.php index fd68638d2..f1e279d61 100644 --- a/app/Models/V5/Application.php +++ b/app/Models/V5/Application.php @@ -8,6 +8,7 @@ use App\Models\Project; use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Application extends V5Model { @@ -26,6 +27,8 @@ class Application extends V5Model 'status_message', 'runtime_container_id', 'mesh_namespace', + 'ingress_enabled', + 'internal_port', 'canvas_x', 'canvas_y', ]; @@ -33,6 +36,7 @@ class Application extends V5Model protected $attributes = [ 'status' => 'creating', 'mesh_namespace' => 'default', + 'ingress_enabled' => false, 'canvas_x' => 0, 'canvas_y' => 0, ]; @@ -49,6 +53,8 @@ class Application extends V5Model protected function casts(): array { return [ + 'ingress_enabled' => 'boolean', + 'internal_port' => 'integer', 'canvas_x' => 'integer', 'canvas_y' => 'integer', ]; @@ -78,4 +84,9 @@ class Application extends V5Model { return $this->belongsTo(User::class, 'created_by_user_id'); } + + public function domains(): HasMany + { + return $this->hasMany(ApplicationDomain::class); + } } diff --git a/app/Models/V5/ApplicationDomain.php b/app/Models/V5/ApplicationDomain.php new file mode 100644 index 000000000..5cabe6207 --- /dev/null +++ b/app/Models/V5/ApplicationDomain.php @@ -0,0 +1,20 @@ +belongsTo(Application::class); + } +} diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php index 2cd5e28ab..0a5ff9d64 100644 --- a/app/Services/Flux/FluxClient.php +++ b/app/Services/Flux/FluxClient.php @@ -21,6 +21,30 @@ class FluxClient return is_array($data) ? $data : []; } + /** + * @param array $apps + */ + public function applyCaddyIngress(string $hostId, string $caddyfile, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'apply_caddy_ingress', + 'caddyfile' => $caddyfile, + 'apps' => $apps, + 'mesh_network' => $meshNetwork, + ]); + + return $this->output($payload, 'Caddy ingress applied.'); + } + + public function stopCaddyIngress(string $hostId): string + { + $payload = $this->dispatch($hostId, [ + 'type' => 'stop_caddy_ingress', + ]); + + return $this->output($payload, 'Caddy ingress stopped.'); + } + /** * @param array $command * @return array @@ -84,4 +108,15 @@ class FluxClient return $payload; } + + /** + * @param array $payload + */ + private function output(array $payload, string $fallback): string + { + $data = $payload['data'] ?? []; + $output = is_array($data) && is_string($data['output'] ?? null) ? $data['output'] : ''; + + return $output !== '' ? $output : $fallback; + } } 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 new file mode 100644 index 000000000..0b8c45176 --- /dev/null +++ b/database/migrations/2026_06_20_072818_v5_add_ingress_routing_to_applications_table.php @@ -0,0 +1,40 @@ +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 46bf06a95..63c326e1f 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -1416,12 +1416,22 @@ CREATE TABLE IF NOT EXISTS "v5_applications" ( "status_message" TEXT, "runtime_container_id" TEXT, "mesh_namespace" TEXT DEFAULT 'default' NOT NULL, + "ingress_enabled" INTEGER DEFAULT false NOT NULL, + "internal_port" INTEGER, "canvas_x" INTEGER DEFAULT '0' NOT NULL, "canvas_y" INTEGER DEFAULT '0' NOT NULL, "created_at" TEXT, "updated_at" TEXT ); +CREATE TABLE IF NOT EXISTS "v5_application_domains" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "application_id" INTEGER NOT NULL, + "domain" TEXT 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, @@ -1537,6 +1547,7 @@ CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changel CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); 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_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); @@ -1861,3 +1872,4 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_1 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); diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index ec0375ebf..1e46d60c1 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -335,6 +335,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection async function persistNewConnection(fromApplicationId: string, toApplicationId: string): Promise { setNotice(null); + try { const response = await fetch('/v5/resource-connections', { method: 'POST', @@ -378,6 +379,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection ]), ); + try { const response = await fetch(`/v5/resource-connections/${connection.id}`, { method: 'PATCH', @@ -409,6 +411,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection } async function deletePersistedConnection(connectionId: string): Promise { + try { const response = await fetch(`/v5/resource-connections/${connectionId}`, { method: 'DELETE', @@ -615,6 +618,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection async function removeApplication(application: V5Application): Promise { setNotice(null); + try { const response = await fetch(`/v5/applications/${application.id}`, { method: 'DELETE', @@ -645,10 +649,73 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection } } + async function updateApplicationIngress(application: V5Application, enabled: boolean): Promise { + 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.'); + + return; + } + + const internalPort = enabled + ? Number(window.prompt('Internal container port', String(application.internalPort ?? ''))) + : application.internalPort; + + if (enabled && (!Number.isInteger(internalPort) || Number(internalPort) < 1 || Number(internalPort) > 65535)) { + setNotice('Choose a valid internal port before enabling app ingress.'); + + return; + } + + const selectedInternalPort = enabled ? Number(internalPort) : application.internalPort; + + try { + const response = await fetch(`/v5/applications/${application.id}/ingress`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + ingress_enabled: enabled, + internal_port: selectedInternalPort, + domains, + }), + }); + + if (!response.ok) { + setNotice('Could not update application ingress.'); + + return; + } + + const payload = (await response.json()) as { application: V5Application }; + setApplications((currentApplications) => + currentApplications.map((candidate) => + candidate.id === payload.application.id ? payload.application : candidate, + ), + ); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not update application ingress.'); + } + } + async function addNginx(): Promise { setIsCreating(true); setNotice(null); + try { const response = await fetch('/v5/applications/nginx', { method: 'POST', @@ -697,6 +764,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection setIsRefreshing(true); setNotice(null); + try { const response = await fetch('/v5/applications/refresh', { method: 'POST', @@ -1476,6 +1544,27 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {application.containerName} +
+
Ingress
+
+ + {application.ingressEnabled + ? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}` + : 'Private'} + + +
+
))} diff --git a/resources/js/v5/types.ts b/resources/js/v5/types.ts index 9164509be..b003f48fc 100644 --- a/resources/js/v5/types.ts +++ b/resources/js/v5/types.ts @@ -91,6 +91,9 @@ export type V5Application = { runtimeContainerId: string | null; serverName: string | null; meshNamespace: string; + ingressEnabled: boolean; + internalPort: number | null; + domains: string[]; meshFqdn: string; canvasX: number; canvasY: number; diff --git a/routes/v5.php b/routes/v5.php index e7986157f..afe9e249c 100644 --- a/routes/v5.php +++ b/routes/v5.php @@ -12,6 +12,7 @@ Route::middleware('v5.authenticated')->group(function () { Route::post('/applications/refresh', [DashboardController::class, 'refreshApplications'])->name('applications.refresh'); Route::delete('/applications/{application}', [DashboardController::class, 'destroyApplication'])->name('applications.destroy'); Route::patch('/applications/{application}/position', [DashboardController::class, 'updateApplicationPosition'])->name('applications.position'); + Route::patch('/applications/{application}/ingress', [DashboardController::class, 'updateApplicationIngress'])->name('applications.ingress'); Route::patch('/caddy-ingresses/{server}/position', [DashboardController::class, 'updateCaddyIngressPosition'])->name('caddy-ingresses.position'); Route::post('/resource-connections', [DashboardController::class, 'storeResourceConnection'])->name('resource-connections.store'); Route::patch('/resource-connections/{connection}', [DashboardController::class, 'updateResourceConnection'])->name('resource-connections.update'); diff --git a/scripts/dev.sh b/scripts/dev.sh index 4a86db1c8..3cf03c1e4 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -122,10 +122,6 @@ ensure_coolify() { arch="$(host_arch)" os="$(host_os)" - if [ -x "$bin" ] && "$bin" --version >/dev/null 2>&1 && [ "${COOLIFY_CLI_FORCE_DOWNLOAD:-false}" != "true" ]; then - return - fi - url="https://github.com/coollabsio/coold/releases/download/${version}/coolify-${os}-${arch}.tar.gz" echo "==> Installing coolify from ${url}" diff --git a/tests/Feature/DevScriptFirewallDelegationTest.php b/tests/Feature/DevScriptFirewallDelegationTest.php index 9c6669478..d001d39f5 100644 --- a/tests/Feature/DevScriptFirewallDelegationTest.php +++ b/tests/Feature/DevScriptFirewallDelegationTest.php @@ -22,6 +22,15 @@ it('installs the coolify CLI in both application container images', function (st 'production image' => 'docker/production/Dockerfile', ]); +it('refreshes the host coolify CLI on every dev script run', function () { + $script = file_get_contents(base_path('scripts/dev.sh')); + + expect($script)->toContain('url="https://github.com/coollabsio/coold/releases/download/${version}/coolify-${os}-${arch}.tar.gz"') + ->and($script)->toContain('==> Installing coolify from ${url}') + ->and($script)->not->toContain('COOLIFY_CLI_FORCE_DOWNLOAD') + ->and($script)->not->toContain('if [ -x "$bin" ] && "$bin" --version >/dev/null 2>&1'); +}); + it('does not require predefined UI node environment variables in the development app container', function () { $compose = file_get_contents(base_path('docker-compose.dev.yml')); $config = file_get_contents(base_path('config/coold.php')); diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index 39a95fe51..d599b480c 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -17,6 +17,7 @@ use App\Models\Project; use App\Models\Team; use App\Models\User; use App\Models\V5\Application as V5Application; +use App\Models\V5\ApplicationDomain as V5ApplicationDomain; use App\Models\V5\Cluster; use App\Models\V5\ContainerStatus; use App\Models\V5\Server as V5Server; @@ -40,6 +41,7 @@ beforeEach(function () { Schema::dropIfExists('v5_resource_connection_rules'); Schema::dropIfExists('v5_resource_connections'); + Schema::dropIfExists('v5_application_domains'); Schema::dropIfExists('v5_applications'); Schema::dropIfExists('v5_container_statuses'); Schema::dropIfExists('v5_servers'); @@ -66,6 +68,7 @@ it('registers the v5 dashboard route', function () { ->and(Route::has('v5.applications.nginx'))->toBeTrue() ->and(Route::has('v5.applications.refresh'))->toBeTrue() ->and(Route::has('v5.applications.position'))->toBeTrue() + ->and(Route::has('v5.applications.ingress'))->toBeTrue() ->and(Route::has('v5.caddy-ingresses.position'))->toBeTrue() ->and(Route::has('v5.applications.destroy'))->toBeTrue() ->and(Route::has('v5.resource-connections.store'))->toBeTrue() @@ -432,6 +435,40 @@ it('creates v5 application tables for dashboard canvas nodes', function () { ]))->toBeTrue(); }); +it('creates v5 application domain tables for zero or more inbound routes', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_application_domains'); + Schema::dropIfExists('v5_applications'); + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); + $clusterMigration->up(); + + $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); + $serverMigration->up(); + + $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', + 'application_id', + 'domain', + 'created_at', + 'updated_at', + ]))->toBeTrue() + ->and(Schema::hasColumns('v5_applications', [ + 'ingress_enabled', + 'internal_port', + ]))->toBeTrue(); +}); + it('creates generic v5 resource connection tables', function () { createSharedUserAndTeamTables(); @@ -516,6 +553,10 @@ it('includes v5 tables in the dev testing schema', function () { ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"') ->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('"domain" TEXT NOT NULL') + ->and($schema)->toContain('"ingress_enabled" INTEGER DEFAULT false NOT NULL') + ->and($schema)->toContain('"internal_port" INTEGER') ->and($schema)->toContain('"cluster_id" INTEGER') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"') ->and($schema)->toContain('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL') @@ -537,6 +578,7 @@ it('includes v5 tables in the dev testing schema', function () { ->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_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_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') @@ -632,7 +674,7 @@ it('serves v5 dashboard applications as canvas nodes', function () { 'capabilities' => ['coold'], ]); - V5Application::query()->create([ + $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, @@ -648,7 +690,7 @@ it('serves v5 dashboard applications as canvas nodes', function () { 'canvas_x' => 120, 'canvas_y' => -80, ]); - V5Application::query()->create([ + $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $otherProject->id, 'environment_id' => $otherEnvironment->id, @@ -952,7 +994,7 @@ it('places a new nginx v5 application next to existing canvas nodes', function ( 'last_bootstrapped_at' => now(), ]); - V5Application::query()->create([ + $application = V5Application::query()->create([ 'team_id' => $team->id, 'project_id' => $project->id, 'environment_id' => $environment->id, @@ -3066,6 +3108,229 @@ it('updates editable v5 server caddy ingress capability independently from build ->and($server->isIngress())->toBeTrue(); }); +it('enables application ingress without publishing domains by default', 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', + ]); + V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => 'kept.example.com', + ]); + + $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')), + [] + ) + ->andReturn('Caddy ingress applied.'); + app()->instance(FluxClient::class, $fluxClient); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/applications/{$application->id}/ingress", [ + 'ingress_enabled' => false, + 'internal_port' => 8080, + ]) + ->assertSuccessful() + ->assertJsonPath('application.ingressEnabled', false) + ->assertJsonPath('application.internalPort', 8080) + ->assertJsonPath('application.domains.0', 'kept.example.com'); + + expect($application->refresh()->ingress_enabled)->toBeFalse(); +}); + +it('enables application ingress with explicit domains and port', 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'], 'app.example.com {') + && 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() + ->assertJsonPath('application.ingressEnabled', true) + ->assertJsonPath('application.internalPort', 3000) + ->assertJsonPath('application.domains.0', 'app.example.com'); + + expect($application->refresh()->ingress_enabled)->toBeTrue() + ->and($application->internal_port)->toBe(3000) + ->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']); +}); + +it('syncs caddy ingress routes through flux when enabling ingress on an installed server', 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', + ]); + V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => 'www.nginx.example.com', + ]); + + $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'], 'nginx.example.com {') + && str_contains($apps[0]['caddyfile'], 'www.nginx.example.com {') + && str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080')) + ) + ->andReturn('Caddy ingress applied.'); + 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, + ]) + ->assertSuccessful() + ->assertJsonPath('cluster.servers.0.ingressEnabled', true); + + expect($server->refresh()->caddy_ingress_status)->toBe('running'); +}); + it('keeps editable v5 server builder capacity when disabling builder', function () { createSharedUserAndTeamTables(); @@ -4305,11 +4570,22 @@ function createSharedUserAndTeamTables(): void $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(); }); + Schema::create('v5_application_domains', function ($table) { + $table->id(); + $table->foreignId('application_id'); + $table->string('domain'); + $table->timestamps(); + + $table->unique(['application_id', 'domain']); + }); + Schema::create('v5_resource_connections', function ($table) { $table->id(); $table->foreignId('team_id'); diff --git a/tests/Unit/V5/CaddyIngressConfigurationTest.php b/tests/Unit/V5/CaddyIngressConfigurationTest.php index 0808caac3..a5cb708a1 100644 --- a/tests/Unit/V5/CaddyIngressConfigurationTest.php +++ b/tests/Unit/V5/CaddyIngressConfigurationTest.php @@ -3,103 +3,124 @@ use App\Actions\V5\Proxy\GenerateCaddyIngressConfiguration; use App\Actions\V5\Proxy\StartCaddyIngress; use App\Actions\V5\Proxy\StopCaddyIngress; -use App\Models\PrivateKey; +use App\Models\V5\Application; +use App\Models\V5\ApplicationDomain; use App\Models\V5\Server; -use Illuminate\Support\Facades\Process; +use App\Services\Flux\FluxClient; +use Illuminate\Support\Collection; use Tests\TestCase; uses(TestCase::class); -it('generates a caddy ingress compose file with health endpoint', function () { - $configuration = GenerateCaddyIngressConfiguration::run(); +it('generates a caddy ingress compose file with health endpoint and application routes', function () { + $application = new Application([ + 'name' => 'nginx-test', + 'container_name' => 'coolify-v5-nginx-test', + 'mesh_namespace' => 'default', + 'ingress_enabled' => true, + 'internal_port' => 8080, + ]); + $application->setRelation('domains', new Collection([ + new ApplicationDomain([ + 'domain' => 'nginx.example.com', + ]), + new ApplicationDomain([ + 'domain' => 'www.nginx.example.com', + ]), + ])); + + $configuration = GenerateCaddyIngressConfiguration::run(new Collection([$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'])->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('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('reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'); }); -it('builds caddy ingress install commands with sudo fallback for non-root ssh users', function () { - $configuration = GenerateCaddyIngressConfiguration::run('/tmp/coolify-caddy'); - $script = implode("\n", $configuration['commands']); +it('does not generate app routes for applications without domains', function () { + $application = new Application([ + 'name' => 'private-app', + 'container_name' => 'coolify-v5-private', + 'mesh_namespace' => 'default', + ]); + $application->setRelation('domains', new Collection); - expect($configuration['commands'])->toHaveCount(6) - ->and($script)->toContain('sudo mkdir -p /tmp/coolify-caddy/data /tmp/coolify-caddy/config') - ->and($script)->toContain('sudo tee /tmp/coolify-caddy/docker-compose.yml') - ->and($script)->toContain('sudo tee /tmp/coolify-caddy/Caddyfile') - ->and($script)->toContain('command -v podman') - ->and($script)->toContain('command -v docker') - ->and(strpos($script, 'command -v podman'))->toBeLessThan(strpos($script, 'command -v docker')) - ->and($script)->toContain('coolify-v5-caddy') - ->and($script)->toContain('-v /tmp/coolify-caddy/Caddyfile:/etc/caddy/Caddyfile:ro'); + $configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application])); + + expect($configuration['caddyfile']) + ->toContain('respond /coolify-health 200') + ->and($configuration['apps'])->toBe([]); }); -it('throws when the caddy ingress start command fails', function () { - $privateKey = new PrivateKey([ - 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n", +it('does not generate app routes when ingress is disabled even with domains', function () { + $application = new Application([ + 'name' => 'private-app', + 'container_name' => 'coolify-v5-private', + 'mesh_namespace' => 'default', + 'ingress_enabled' => false, + 'internal_port' => 8080, ]); + $application->setRelation('domains', new Collection([ + new ApplicationDomain([ + 'domain' => 'private.example.com', + ]), + ])); + $configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application])); + + expect($configuration['apps'])->toBe([]); +}); + +it('does not generate app routes when the internal port is missing', function () { + $application = new Application([ + 'name' => 'needs-port', + 'container_name' => 'coolify-v5-needs-port', + 'mesh_namespace' => 'default', + 'ingress_enabled' => true, + 'internal_port' => null, + ]); + $application->setRelation('domains', new Collection([ + new ApplicationDomain([ + 'domain' => 'needs-port.example.com', + ]), + ])); + + $configuration = GenerateCaddyIngressConfiguration::run(new Collection([$application])); + + expect($configuration['apps'])->toBe([]); +}); + +it('applies caddy ingress configuration through flux instead of ssh', function () { $server = new Server([ - 'host' => '203.0.113.10', - 'ssh_user' => 'root', - 'ssh_port' => 22, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '10.0.0.10', 'capabilities' => ['coold', 'ingress'], ]); - $server->setRelation('privateKey', $privateKey); - Process::fake([ - '*' => Process::result(errorOutput: 'mkdir: Permission denied', exitCode: 1), - ]); - - StartCaddyIngress::run($server); -})->throws(RuntimeException::class, 'Failed to start Caddy ingress: mkdir: Permission denied'); - -it('starts caddy ingress over ssh for ingress servers', function () { - $privateKey = new PrivateKey([ - 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n", - ]); - - $server = new Server([ - 'host' => '203.0.113.10', - 'ssh_user' => 'root', - 'ssh_port' => 22, - 'capabilities' => ['coold', 'ingress'], - ]); - $server->setRelation('privateKey', $privateKey); - - Process::fake([ - '*' => Process::result(output: ''), - ]); + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient + ->shouldReceive('applyCaddyIngress') + ->once() + ->with( + '100.64.0.10', + Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')), + [] + ) + ->andReturn('Caddy ingress applied.'); + app()->instance(FluxClient::class, $fluxClient); $result = StartCaddyIngress::run($server); - expect($result)->toBe('Caddy ingress started.'); - - Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; - - return is_string($command) - && str_contains($command, 'command -v podman') - && str_contains($command, 'command -v docker') - && strpos($command, 'command -v podman') < strpos($command, 'command -v docker') - && str_contains($command, 'coolify-v5-caddy'); - }); -}); - -it('prefers podman for every caddy ingress runtime command', function () { - $configuration = GenerateCaddyIngressConfiguration::run('/tmp/coolify-caddy'); - - $runtimeCommands = collect($configuration['commands']) - ->filter(fn (string $command) => str_contains($command, 'command -v podman') && str_contains($command, 'command -v docker')); - - expect($runtimeCommands)->toHaveCount(3); - - $runtimeCommands->each(function (string $command): void { - expect(strpos($command, 'command -v podman'))->toBeLessThan(strpos($command, 'command -v docker')); - }); + expect($result)->toBe('Caddy ingress applied.'); }); it('does not start caddy ingress for non-ingress servers', function () { @@ -107,43 +128,27 @@ it('does not start caddy ingress for non-ingress servers', function () { 'capabilities' => ['coold'], ]); - Process::fake(); - $result = StartCaddyIngress::run($server); expect($result)->toBe('Server is not an ingress server.'); - - Process::assertNothingRan(); }); -it('stops caddy ingress over ssh', function () { - $privateKey = new PrivateKey([ - 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n", - ]); - +it('stops caddy ingress through flux instead of ssh', function () { $server = new Server([ - 'host' => '203.0.113.10', - 'ssh_user' => 'root', - 'ssh_port' => 22, + 'wireguard_management_ip' => '100.64.0.10', + 'node_address' => '10.0.0.10', 'capabilities' => ['coold'], ]); - $server->setRelation('privateKey', $privateKey); - Process::fake([ - '*' => Process::result(output: ''), - ]); + $fluxClient = Mockery::mock(FluxClient::class); + $fluxClient + ->shouldReceive('stopCaddyIngress') + ->once() + ->with('100.64.0.10') + ->andReturn('Caddy ingress stopped.'); + app()->instance(FluxClient::class, $fluxClient); $result = StopCaddyIngress::run($server); expect($result)->toBe('Caddy ingress stopped.'); - - Process::assertRan(function ($process): bool { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; - - return is_string($command) - && str_contains($command, 'command -v podman') - && str_contains($command, 'command -v docker') - && strpos($command, 'command -v podman') < strpos($command, 'command -v docker') - && str_contains($command, 'coolify-v5-caddy'); - }); });