mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 20:17:05 +00:00
feat(v5): add application ingress routing
This commit is contained in:
1 parent
86156b6f7a
commit
ad0bcc8a4a
16 files changed
+763
-235
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user