mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 22:23:42 +00:00
feat(v5): add application ingress routing
This commit is contained in:
@@ -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<int, string>}
|
||||
* @param Collection<int, Application>|null $applications
|
||||
* @return array{compose: string, caddyfile: string, apps: array<int, array{name: string, caddyfile: string}>}
|
||||
*/
|
||||
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<int, Application> $applications
|
||||
* @return array<int, array{name: string, caddyfile: string}>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, Application>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApplicationDomain extends V5Model
|
||||
{
|
||||
protected $table = 'v5_application_domains';
|
||||
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'domain',
|
||||
];
|
||||
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,30 @@ class FluxClient
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $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<string, mixed> $command
|
||||
* @return array<string, mixed>
|
||||
@@ -84,4 +108,15 @@ class FluxClient
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('v5_applications', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -335,6 +335,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
async function persistNewConnection(fromApplicationId: string, toApplicationId: string): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<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.');
|
||||
|
||||
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<void> {
|
||||
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}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">Ingress</dt>
|
||||
<dd className="flex items-center justify-end gap-2 text-right">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{application.ingressEnabled
|
||||
? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}`
|
||||
: 'Private'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void updateApplicationIngress(application, !application.ingressEnabled);
|
||||
}}
|
||||
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted"
|
||||
>
|
||||
{application.ingressEnabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user