diff --git a/.dev/lima/coolify-naked-test.yaml b/.dev/lima/coolify-naked-test.yaml new file mode 100644 index 000000000..90850f551 --- /dev/null +++ b/.dev/lima/coolify-naked-test.yaml @@ -0,0 +1,32 @@ +vmType: "vz" +arch: "default" +cpus: 2 +memory: "2GiB" +disk: "20GiB" +containerd: + system: false + user: false +ssh: + localPort: 60003 +images: + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" + arch: "x86_64" + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img" + arch: "aarch64" +mounts: [] +provision: + - mode: system + script: | + #!/usr/bin/env bash + set -euxo pipefail + export DEBIAN_FRONTEND=noninteractive + install -d -m 700 /root/.ssh + cat >/root/.ssh/authorized_keys <<'KEYS' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd + KEYS + chmod 600 /root/.ssh/authorized_keys + sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config + sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh || systemctl restart sshd + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl openssh-server sudo diff --git a/.dev/testing-host-key b/.dev/testing-host-key new file mode 100644 index 000000000..c67d90a64 --- /dev/null +++ b/.dev/testing-host-key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- diff --git a/.dev/testing-host-key.pub b/.dev/testing-host-key.pub new file mode 100644 index 000000000..802cd8b80 --- /dev/null +++ b/.dev/testing-host-key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd diff --git a/app/Console/Commands/V5SyncDevLimaServers.php b/app/Console/Commands/V5SyncDevLimaServers.php index b6f30ebef..d55e3531f 100644 --- a/app/Console/Commands/V5SyncDevLimaServers.php +++ b/app/Console/Commands/V5SyncDevLimaServers.php @@ -33,7 +33,13 @@ class V5SyncDevLimaServers extends Command $team = Team::query()->find((int) $this->option('team-id')) ?? Team::query()->orderBy('id')->first(); $user = User::query()->find((int) $this->option('user-id')) ?? User::query()->orderBy('id')->first(); $privateKeyId = $this->option('private-key-id'); - $privateKey = is_numeric($privateKeyId) ? PrivateKey::query()->find((int) $privateKeyId) : null; + $privateKey = is_numeric($privateKeyId) + ? PrivateKey::query()->find((int) $privateKeyId) + : PrivateKey::query() + ->where('team_id', $team?->id) + ->where('is_git_related', false) + ->orderBy('id') + ->first(); if (! $team instanceof Team || ! $user instanceof User) { $this->warn('Cannot sync dev Lima servers without an existing team and user.'); @@ -74,14 +80,14 @@ class V5SyncDevLimaServers extends Command Server::query()->updateOrCreate([ 'team_id' => $team->id, - 'host' => $host, - 'ssh_port' => (int) $sshPort, - ], [ 'cluster_id' => $cluster->id, + 'name' => $name, + ], [ 'created_by_user_id' => $user->id, 'private_key_id' => $privateKey?->id, - 'name' => $name, + 'host' => $host, 'ssh_user' => $sshUser, + 'ssh_port' => (int) $sshPort, 'status' => 'installed', 'capabilities' => $capabilities, 'builder_enabled' => $builderEnabled, diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 0e1c5a283..ef4a9a376 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\V5; use App\Http\Controllers\Controller; use App\Models\Environment; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Team; use App\Models\V5\Cluster as V5Cluster; @@ -12,6 +13,8 @@ use App\Services\Flux\FluxHealth; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; @@ -45,6 +48,7 @@ class DashboardController extends Controller return Inertia::render('Clusters', [ 'flux' => $fluxHealth->check(), 'clusters' => $this->clusters($currentTeam), + 'privateKeys' => $this->privateKeys($currentTeam), 'projects' => $projects, 'selectedProjectUuid' => $selectedProject['uuid'] ?? null, 'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null, @@ -98,9 +102,28 @@ class DashboardController extends Controller Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id), ], 'description' => ['nullable', 'string', 'max:1000'], + 'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'], + 'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], + 'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()], + 'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'], + 'namespaces' => ['sometimes', 'array', 'min:1'], + 'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'], + 'default_deny_containers' => ['sometimes', 'boolean'], + 'coold_version' => ['sometimes', 'string', 'max:64'], + 'corrosion_version' => ['sometimes', 'string', 'max:64'], + 'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'], + 'builder_enabled' => ['sometimes', 'boolean'], + 'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'], + 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], + 'builder_memory_max' => ['sometimes', 'string', 'max:32'], + 'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'], ]); $cluster = V5Cluster::query()->create([ + ...$this->defaultClusterConfiguration(), + ...collect($validated)->except(['name', 'description'])->all(), 'team_id' => $currentTeam->id, 'created_by_user_id' => $request->user()->id, 'name' => $validated['name'], @@ -117,8 +140,587 @@ class DashboardController extends Controller ], 201); } + public function bootstrapServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + if ($server->last_bootstrapped_at !== null) { + return response()->json([ + 'message' => 'This server is already bootstrapped.', + ], 409); + } + + $installedServers = $cluster->servers() + ->with('privateKey') + ->whereNotNull('last_bootstrapped_at') + ->orderBy('name') + ->get(); + $action = $installedServers->isEmpty() ? 'bootstrap' : 'extend'; + $server->load('privateKey'); + $servers = $installedServers->toBase() + ->push($server) + ->unique('id') + ->values(); + + if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) { + return response()->json([ + 'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.', + ], 422); + } + + $cluster->update([ + 'last_cli_action' => $action, + 'last_cli_status' => 'running', + 'last_cli_summary' => "Starting Coolify CLI {$action} for {$server->name}...", + 'last_cli_ran_at' => now(), + ]); + + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16); + if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) { + $cluster->update([ + 'last_cli_status' => 'failed', + 'last_cli_summary' => 'Could not create a temporary SSH configuration directory.', + 'last_cli_ran_at' => now(), + ]); + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ], 500); + } + + try { + $sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory); + $result = Process::timeout(max(60, (int) $cluster->builder_timeout_secs + 120)) + ->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action)); + $output = trim($result->output()."\n".$result->errorOutput()); + $successful = $result->successful(); + } catch (\Throwable $e) { + $output = $e->getMessage(); + $successful = false; + } finally { + $this->deleteDirectory($tempDirectory); + } + + $cluster->update([ + 'last_cli_action' => $action, + 'last_cli_status' => $successful ? 'succeeded' : 'failed', + 'last_cli_summary' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(), + 'last_cli_ran_at' => now(), + ]); + + if ($successful) { + $capabilities = collect($server->capabilities ?? []) + ->push('coold') + ->when($server->builder_enabled, fn ($capabilities) => $capabilities->push('builder')) + ->unique() + ->values() + ->all(); + + $server->update([ + 'status' => 'installed', + 'capabilities' => $capabilities, + 'last_bootstrapped_at' => now(), + ]); + } + + $payload = [ + 'cluster' => $this->freshSerializedCluster($cluster), + ]; + + if (! $successful) { + $payload['message'] = $cluster->last_cli_summary; + } + + return response()->json($payload, $successful ? 200 : 500); + } + + public function storeServer(Request $request, V5Cluster $cluster): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { + abort(403); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'host' => [ + 'required', + 'string', + 'max:255', + Rule::unique('v5_servers', 'host') + ->where('team_id', $currentTeam->id) + ->where('ssh_port', (int) $request->input('ssh_port', 22)), + ], + 'ssh_user' => ['required', 'string', 'max:255'], + 'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'], + 'private_key_id' => [ + 'required', + 'integer', + Rule::exists('private_keys', 'id')->where('team_id', $currentTeam->id), + ], + 'node_address' => ['nullable', 'string', 'max:255'], + 'builder_enabled' => ['sometimes', 'boolean'], + 'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'], + 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], + 'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'], + ]); + + $builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled); + $builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity); + $builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota; + $devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']); + + V5Server::query()->create([ + 'team_id' => $currentTeam->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $request->user()->id, + 'name' => $validated['name'], + 'host' => $validated['host'], + 'ssh_user' => $validated['ssh_user'], + 'ssh_port' => $validated['ssh_port'], + 'private_key_id' => $validated['private_key_id'] ?? null, + 'status' => 'pending', + 'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'], + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => $builderEnabled ? $builderCapacity : 0, + 'builder_cpu_quota' => $builderCpuQuota, + 'node_address' => $validated['node_address'] ?? $validated['host'], + 'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'], + 'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'], + ]); + + $cluster->load(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]); + $cluster->loadCount('servers'); + + return response()->json([ + 'cluster' => $this->serializeCluster($cluster), + ], 201); + } + + public function updateServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + $validated = $request->validate([ + 'builder_enabled' => ['required', 'boolean'], + 'builder_capacity' => ['required', 'integer', 'min:0', 'max:1000'], + 'builder_cpu_quota' => ['required', 'string', 'max:32'], + ]); + + $builderEnabled = (bool) $validated['builder_enabled']; + $capabilities = collect($server->capabilities ?? []) + ->push('coold') + ->when($builderEnabled, fn ($capabilities) => $capabilities->push('builder')) + ->when(! $builderEnabled, fn ($capabilities) => $capabilities->reject(fn (string $capability) => $capability === 'builder')) + ->unique() + ->values() + ->all(); + + $server->update([ + 'capabilities' => $capabilities, + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => $builderEnabled ? (int) $validated['builder_capacity'] : 0, + 'builder_cpu_quota' => $validated['builder_cpu_quota'], + ]); + + $cluster->load(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]); + $cluster->loadCount('servers'); + + return response()->json([ + 'cluster' => $this->serializeCluster($cluster), + ]); + } + + public function checkServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + if (! $server->privateKey instanceof PrivateKey) { + $server->update([ + 'last_status_check' => 'failed', + 'last_status_output' => 'No private key is attached to this server.', + 'last_status_checked_at' => now(), + ]); + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ]); + } + + $keyDirectory = storage_path('app/ssh/keys'); + if (! is_dir($keyDirectory)) { + mkdir($keyDirectory, 0700, true); + } + + $keyLocation = tempnam($keyDirectory, 'v5_ssh_key_'); + if ($keyLocation === false) { + $server->update([ + 'last_status_check' => 'failed', + 'last_status_output' => 'Could not create a temporary SSH key file.', + 'last_status_checked_at' => now(), + ]); + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ]); + } + + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + $target = "{$server->ssh_user}@{$server->host}"; + $command = [ + '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, + $target, + "printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true", + ]; + + try { + $result = Process::timeout(15)->run($command); + $output = trim($result->output()."\n".$result->errorOutput()); + $status = $result->successful() ? 'reachable' : 'failed'; + } catch (\Throwable $e) { + $output = $e->getMessage(); + $status = 'failed'; + } finally { + @unlink($keyLocation); + } + + $server->update([ + 'last_status_check' => $status, + 'last_status_output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(), + 'last_status_checked_at' => now(), + ]); + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ]); + } + + public function destroyServer(Request $request, V5Cluster $cluster, V5Server $server): \Illuminate\Http\Response|JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if ( + ! $currentTeam instanceof Team + || $cluster->team_id !== $currentTeam->id + || $server->team_id !== $currentTeam->id + || $server->cluster_id !== $cluster->id + ) { + abort(404); + } + + if ($server->last_bootstrapped_at !== null) { + return response()->json([ + 'message' => 'Only unbootstrapped servers can be deleted.', + ], 409); + } + + $server->delete(); + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ]); + } + + public function destroyCluster(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { + abort(404); + } + + if ($cluster->servers()->exists()) { + return response()->json([ + 'message' => 'Only empty clusters can be deleted.', + ], 422); + } + + $cluster->delete(); + + return response()->noContent(); + } + /** - * @return array, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>}> + * @param Collection $servers + * @return array + */ + /** + * @return array{listen_port: int|null, endpoint: string|null} + */ + private function devLimaWireguardOverrides(string $host, int $sshPort): array + { + if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') { + return ['listen_port' => null, 'endpoint' => null]; + } + + if ($sshPort < 60001 || $sshPort > 60009) { + return ['listen_port' => null, 'endpoint' => null]; + } + + $wireguardPort = $sshPort - 8180; + + return [ + 'listen_port' => $wireguardPort, + 'endpoint' => "host.lima.internal:{$wireguardPort}", + ]; + } + + private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array + { + $command = [ + $this->coolifyCliBin(), + 'init', + $action, + '--format', + 'table', + '--nodes', + $servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','), + '--ssh-config', + $sshConfigLocation, + '--namespaces', + implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES), + '--container-pool', + $cluster->container_network_pool, + '--container-prefix', + (string) $cluster->container_network_prefix, + '--wg-mgmt-pool', + $cluster->wireguard_management_pool, + '--wg-interface', + $cluster->wireguard_interface, + '--wg-listen-port', + (string) $cluster->wireguard_listen_port, + '--coold-version', + $cluster->coold_version, + '--corrosion-version', + $cluster->corrosion_version, + '--corrosion-gossip-port', + (string) $cluster->corrosion_gossip_port, + '--corrosion-api-port', + (string) $cluster->corrosion_api_port, + ]; + + if ($action === 'extend') { + array_push($command, '--new-nodes', $this->bootstrapNode($newServer)); + } + + $listenOverrides = $this->wireguardListenPortOverrides($servers); + if ($listenOverrides !== '') { + array_push($command, '--wg-listen-port-overrides', $listenOverrides); + } + + $endpointOverrides = $this->wireguardEndpointOverrides($servers); + if ($endpointOverrides !== '') { + array_push($command, '--wg-endpoint-overrides', $endpointOverrides); + } + + if (! $cluster->default_deny_containers) { + $command[] = '--skip-default-deny'; + } + + $builderServers = $servers->filter(fn (V5Server $server) => $server->builder_enabled); + if ($cluster->builder_enabled && $builderServers->isNotEmpty()) { + array_push( + $command, + '--enable-builder', + '--builder-hosts', + $builderServers + ->map(fn (V5Server $server) => $this->bootstrapNode($server)) + ->implode(','), + '--builder-capacity', + (string) $cluster->builder_capacity, + '--builder-cpu-quota', + $cluster->builder_cpu_quota, + '--builder-memory-max', + $cluster->builder_memory_max, + '--builder-timeout-secs', + (string) $cluster->builder_timeout_secs, + ); + } + + $command[] = '--yes'; + + return $command; + } + + private function coolifyCliBin(): string + { + $configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify'); + $devBinary = base_path('.dev/bin/coolify'); + + if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) { + return $devBinary; + } + + return $configuredBinary; + } + + private function isRunnableDevelopmentCliBinary(string $binary): bool + { + if (! is_file($binary)) { + return false; + } + + $header = file_get_contents($binary, false, null, 0, 4); + + if ($header === false) { + return false; + } + + if (str_starts_with($header, '#!')) { + return true; + } + + if ($header === "\x7FELF") { + return true; + } + + return false; + } + + private function bootstrapNode(V5Server $server): string + { + return "v5-server-{$server->id}"; + } + + /** + * @param Collection $servers + */ + private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string + { + $config = ''; + + $servers->each(function (V5Server $server) use (&$config, $tempDirectory): void { + $keyLocation = "{$tempDirectory}/server-{$server->id}.key"; + file_put_contents($keyLocation, $server->privateKey->private_key); + chmod($keyLocation, 0600); + + $config .= implode("\n", [ + 'Host '.$this->bootstrapNode($server), + ' HostName '.$server->host, + ' Port '.$server->ssh_port, + ' User '.$server->ssh_user, + ' IdentityFile '.$keyLocation, + ' IdentitiesOnly yes', + ' LogLevel ERROR', + ' StrictHostKeyChecking no', + ' UserKnownHostsFile /dev/null', + ' BatchMode yes', + '', + ]); + }); + + $sshConfigLocation = "{$tempDirectory}/ssh.config"; + file_put_contents($sshConfigLocation, $config); + chmod($sshConfigLocation, 0600); + + return $sshConfigLocation; + } + + private function deleteDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $path = "{$directory}/{$file}"; + + if (is_dir($path)) { + $this->deleteDirectory($path); + + continue; + } + + @unlink($path); + } + + @rmdir($directory); + } + + /** + * @param Collection $servers + */ + private function wireguardListenPortOverrides(Collection $servers): string + { + return $servers + ->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null) + ->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override) + ->implode(','); + } + + /** + * @param Collection $servers + */ + private function wireguardEndpointOverrides(Collection $servers): string + { + return $servers + ->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null) + ->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override) + ->implode(','); + } + + /** + * @return array> */ private function clusters(mixed $currentTeam): array { @@ -139,7 +741,28 @@ class DashboardController extends Controller } /** - * @return array{id: string, name: string, description: string|null, serversCount: int, servers: array, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>} + * @return array + */ + private function privateKeys(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->where('is_git_related', false) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (PrivateKey $privateKey) => [ + 'id' => (string) $privateKey->id, + 'name' => $privateKey->name, + ]) + ->all(); + } + + /** + * @return array */ private function serializeCluster(V5Cluster $cluster): array { @@ -147,23 +770,111 @@ class DashboardController extends Controller 'id' => (string) $cluster->id, 'name' => $cluster->name, 'description' => $cluster->description, + 'wireguardInterface' => $cluster->wireguard_interface, + 'wireguardManagementPool' => $cluster->wireguard_management_pool, + 'wireguardListenPort' => $cluster->wireguard_listen_port, + 'containerNetworkPool' => $cluster->container_network_pool, + 'containerNetworkPrefix' => $cluster->container_network_prefix, + 'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES, + 'defaultDenyContainers' => $cluster->default_deny_containers, + 'cooldVersion' => $cluster->coold_version, + 'corrosionVersion' => $cluster->corrosion_version, + 'corrosionGossipPort' => $cluster->corrosion_gossip_port, + 'corrosionApiPort' => $cluster->corrosion_api_port, + 'builderEnabled' => $cluster->builder_enabled, + 'builderCapacity' => $cluster->builder_capacity, + 'builderCpuQuota' => $cluster->builder_cpu_quota, + 'builderMemoryMax' => $cluster->builder_memory_max, + 'builderTimeoutSecs' => $cluster->builder_timeout_secs, + 'lastCliAction' => $cluster->last_cli_action, + 'lastCliStatus' => $cluster->last_cli_status, + 'lastCliSummary' => $cluster->last_cli_summary, + 'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(), 'serversCount' => $cluster->servers_count ?? $cluster->servers->count(), 'servers' => $cluster->servers->map(fn (V5Server $server) => [ 'id' => (string) $server->id, 'name' => $server->name, 'host' => $server->host, - 'sshUser' => $server->ssh_user, - 'sshPort' => $server->ssh_port, 'status' => $server->status, 'capabilities' => $server->capabilities ?? [], 'builderEnabled' => $server->builder_enabled, 'builderCapacity' => $server->builder_capacity, + 'builderCpuQuota' => $server->builder_cpu_quota, + 'nodeAddress' => $server->node_address, + 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, + 'wireguardEndpointOverride' => $server->wireguard_endpoint_override, + 'wireguardManagementIp' => $server->wireguard_management_ip, + 'wireguardPublicKey' => $server->wireguard_public_key, + 'containerSubnets' => $server->container_subnets ?? [], 'privateKeyName' => $server->privateKey?->name, 'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(), + 'lastStatusCheck' => $server->last_status_check, + 'lastStatusOutput' => $server->last_status_output, + 'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(), ])->all(), ]; } + /** + * @return array + */ + private function freshSerializedCluster(V5Cluster $cluster): array + { + $cluster->load(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]); + $cluster->loadCount('servers'); + + return $this->serializeCluster($cluster); + } + + /** + * @return array + */ + private function defaultClusterConfiguration(): array + { + return [ + 'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE, + 'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL, + 'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT, + 'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL, + 'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX, + 'namespaces' => V5Cluster::DEFAULT_NAMESPACES, + 'default_deny_containers' => true, + 'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION, + 'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION, + 'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT, + 'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT, + 'builder_enabled' => true, + 'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY, + 'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA, + 'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX, + 'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS, + ]; + } + + private function ipv4CidrRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || ! str_contains($value, '/')) { + $fail('The :attribute must be a valid IPv4 CIDR range.'); + + return; + } + + [$ip, $prefix] = explode('/', $value, 2); + + if ( + filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false + || ! ctype_digit($prefix) + || (int) $prefix < 0 + || (int) $prefix > 32 + ) { + $fail('The :attribute must be a valid IPv4 CIDR range.'); + } + }; + } + /** * @param array}> $projects * @return array{0: array{uuid: string, name: string, environments: array}|null, 1: array{uuid: string, name: string}|null} diff --git a/app/Models/V5/Cluster.php b/app/Models/V5/Cluster.php index 00fe98128..b5007d685 100644 --- a/app/Models/V5/Cluster.php +++ b/app/Models/V5/Cluster.php @@ -11,13 +11,89 @@ class Cluster extends V5Model { protected $table = 'v5_clusters'; + public const DEFAULT_WIREGUARD_INTERFACE = 'wg0'; + + public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16'; + + public const DEFAULT_WIREGUARD_LISTEN_PORT = 51820; + + public const DEFAULT_CONTAINER_NETWORK_POOL = '10.210.0.0/16'; + + public const DEFAULT_CONTAINER_NETWORK_PREFIX = 24; + + public const DEFAULT_NAMESPACES = ['default']; + + public const DEFAULT_COOLD_VERSION = 'nightly'; + + public const DEFAULT_CORROSION_VERSION = 'v1.0.0'; + + public const DEFAULT_CORROSION_GOSSIP_PORT = 8787; + + public const DEFAULT_CORROSION_API_PORT = 8080; + + public const DEFAULT_BUILDER_CAPACITY = 2; + + public const DEFAULT_BUILDER_CPU_QUOTA = '200%'; + + public const DEFAULT_BUILDER_MEMORY_MAX = '2G'; + + public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800; + protected $fillable = [ 'team_id', 'created_by_user_id', 'name', 'description', + 'wireguard_interface', + 'wireguard_management_pool', + 'wireguard_listen_port', + 'container_network_pool', + 'container_network_prefix', + 'namespaces', + 'default_deny_containers', + 'coold_version', + 'corrosion_version', + 'corrosion_gossip_port', + 'corrosion_api_port', + 'builder_enabled', + 'builder_capacity', + 'builder_cpu_quota', + 'builder_memory_max', + 'builder_timeout_secs', + 'last_cli_action', + 'last_cli_status', + 'last_cli_summary', + 'last_cli_ran_at', ]; + protected $attributes = [ + 'wireguard_interface' => self::DEFAULT_WIREGUARD_INTERFACE, + 'wireguard_management_pool' => self::DEFAULT_WIREGUARD_MANAGEMENT_POOL, + 'wireguard_listen_port' => self::DEFAULT_WIREGUARD_LISTEN_PORT, + 'container_network_pool' => self::DEFAULT_CONTAINER_NETWORK_POOL, + 'container_network_prefix' => self::DEFAULT_CONTAINER_NETWORK_PREFIX, + 'default_deny_containers' => true, + 'coold_version' => self::DEFAULT_COOLD_VERSION, + 'corrosion_version' => self::DEFAULT_CORROSION_VERSION, + 'corrosion_gossip_port' => self::DEFAULT_CORROSION_GOSSIP_PORT, + 'corrosion_api_port' => self::DEFAULT_CORROSION_API_PORT, + 'builder_enabled' => true, + 'builder_capacity' => self::DEFAULT_BUILDER_CAPACITY, + 'builder_cpu_quota' => self::DEFAULT_BUILDER_CPU_QUOTA, + 'builder_memory_max' => self::DEFAULT_BUILDER_MEMORY_MAX, + 'builder_timeout_secs' => self::DEFAULT_BUILDER_TIMEOUT_SECS, + ]; + + protected function casts(): array + { + return [ + 'namespaces' => 'array', + 'default_deny_containers' => 'boolean', + 'builder_enabled' => 'boolean', + 'last_cli_ran_at' => 'datetime', + ]; + } + public function team(): BelongsTo { return $this->belongsTo(Team::class); diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index 619f9074d..6b19103d6 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -24,7 +24,17 @@ class Server extends V5Model 'capabilities', 'builder_enabled', 'builder_capacity', + 'builder_cpu_quota', + 'node_address', + 'wireguard_listen_port_override', + 'wireguard_endpoint_override', + 'wireguard_management_ip', + 'wireguard_public_key', + 'container_subnets', 'last_bootstrapped_at', + 'last_status_check', + 'last_status_output', + 'last_status_checked_at', ]; protected function casts(): array @@ -32,7 +42,9 @@ class Server extends V5Model return [ 'capabilities' => 'array', 'builder_enabled' => 'boolean', + 'container_subnets' => 'array', 'last_bootstrapped_at' => 'datetime', + 'last_status_checked_at' => 'datetime', ]; } diff --git a/database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php b/database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php new file mode 100644 index 000000000..8ac987e00 --- /dev/null +++ b/database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php @@ -0,0 +1,90 @@ +string('wireguard_interface')->default('wg0')->after('description'); + $table->string('wireguard_management_pool')->default('100.64.0.0/16')->after('wireguard_interface'); + $table->unsignedInteger('wireguard_listen_port')->default(51820)->after('wireguard_management_pool'); + $table->string('container_network_pool')->default('10.210.0.0/16')->after('wireguard_listen_port'); + $table->unsignedTinyInteger('container_network_prefix')->default(24)->after('container_network_pool'); + $table->json('namespaces')->nullable()->after('container_network_prefix'); + $table->boolean('default_deny_containers')->default(true)->after('namespaces'); + $table->string('coold_version')->default('nightly')->after('default_deny_containers'); + $table->string('corrosion_version')->default('v1.0.0')->after('coold_version'); + $table->unsignedInteger('corrosion_gossip_port')->default(8787)->after('corrosion_version'); + $table->unsignedInteger('corrosion_api_port')->default(8080)->after('corrosion_gossip_port'); + $table->boolean('builder_enabled')->default(true)->after('corrosion_api_port'); + $table->unsignedInteger('builder_capacity')->default(2)->after('builder_enabled'); + $table->string('builder_cpu_quota')->default('200%')->after('builder_capacity'); + $table->string('builder_memory_max')->default('2G')->after('builder_cpu_quota'); + $table->unsignedInteger('builder_timeout_secs')->default(1800)->after('builder_memory_max'); + $table->string('last_cli_action')->nullable()->after('builder_timeout_secs'); + $table->string('last_cli_status')->nullable()->after('last_cli_action'); + $table->text('last_cli_summary')->nullable()->after('last_cli_status'); + $table->timestamp('last_cli_ran_at')->nullable()->after('last_cli_summary'); + }); + + Schema::table('v5_servers', function (Blueprint $table) { + $table->string('builder_cpu_quota')->default('200%')->after('builder_capacity'); + $table->string('node_address')->nullable()->after('builder_cpu_quota'); + $table->unsignedInteger('wireguard_listen_port_override')->nullable()->after('node_address'); + $table->string('wireguard_endpoint_override')->nullable()->after('wireguard_listen_port_override'); + $table->string('wireguard_management_ip')->nullable()->after('wireguard_endpoint_override'); + $table->string('wireguard_public_key')->nullable()->after('wireguard_management_ip'); + $table->json('container_subnets')->nullable()->after('wireguard_public_key'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn([ + 'builder_cpu_quota', + 'node_address', + 'wireguard_listen_port_override', + 'wireguard_endpoint_override', + 'wireguard_management_ip', + 'wireguard_public_key', + 'container_subnets', + ]); + }); + + Schema::table('v5_clusters', function (Blueprint $table) { + $table->dropColumn([ + 'wireguard_interface', + 'wireguard_management_pool', + 'wireguard_listen_port', + 'container_network_pool', + 'container_network_prefix', + 'namespaces', + 'default_deny_containers', + 'coold_version', + 'corrosion_version', + 'corrosion_gossip_port', + 'corrosion_api_port', + 'builder_enabled', + 'builder_capacity', + 'builder_cpu_quota', + 'builder_memory_max', + 'builder_timeout_secs', + 'last_cli_action', + 'last_cli_status', + 'last_cli_summary', + 'last_cli_ran_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php b/database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php new file mode 100644 index 000000000..a07d18d8a --- /dev/null +++ b/database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php @@ -0,0 +1,36 @@ +string('builder_cpu_quota')->default('200%')->after('builder_capacity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasColumn('v5_servers', 'builder_cpu_quota')) { + return; + } + + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn('builder_cpu_quota'); + }); + } +}; diff --git a/database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php b/database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php new file mode 100644 index 000000000..76dd8f2c2 --- /dev/null +++ b/database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php @@ -0,0 +1,34 @@ +string('last_status_check')->nullable()->after('last_bootstrapped_at'); + $table->text('last_status_output')->nullable()->after('last_status_check'); + $table->timestamp('last_status_checked_at')->nullable()->after('last_status_output'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropColumn([ + 'last_status_check', + 'last_status_output', + 'last_status_checked_at', + ]); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index 289b4df2a..c2fde927e 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -1327,6 +1327,26 @@ CREATE TABLE IF NOT EXISTS "v5_clusters" ( "created_by_user_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "description" TEXT, + "wireguard_interface" TEXT DEFAULT 'wg0' NOT NULL, + "wireguard_management_pool" TEXT DEFAULT '100.64.0.0/16' NOT NULL, + "wireguard_listen_port" INTEGER DEFAULT '51820' NOT NULL, + "container_network_pool" TEXT DEFAULT '10.210.0.0/16' NOT NULL, + "container_network_prefix" INTEGER DEFAULT '24' NOT NULL, + "namespaces" JSON, + "default_deny_containers" INTEGER DEFAULT true NOT NULL, + "coold_version" TEXT DEFAULT 'nightly' NOT NULL, + "corrosion_version" TEXT DEFAULT 'v1.0.0' NOT NULL, + "corrosion_gossip_port" INTEGER DEFAULT '8787' NOT NULL, + "corrosion_api_port" INTEGER DEFAULT '8080' NOT NULL, + "builder_enabled" INTEGER DEFAULT true NOT NULL, + "builder_capacity" INTEGER DEFAULT '2' NOT NULL, + "builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL, + "builder_memory_max" TEXT DEFAULT '2G' NOT NULL, + "builder_timeout_secs" INTEGER NOT NULL DEFAULT '1800', + "last_cli_action" TEXT, + "last_cli_status" TEXT, + "last_cli_summary" TEXT, + "last_cli_ran_at" TEXT, "created_at" TEXT, "updated_at" TEXT ); @@ -1345,7 +1365,17 @@ CREATE TABLE IF NOT EXISTS "v5_servers" ( "capabilities" TEXT, "builder_enabled" INTEGER DEFAULT false NOT NULL, "builder_capacity" INTEGER DEFAULT '0' NOT NULL, + "builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL, + "node_address" TEXT, + "wireguard_listen_port_override" INTEGER, + "wireguard_endpoint_override" TEXT, + "wireguard_management_ip" TEXT, + "wireguard_public_key" TEXT, + "container_subnets" JSON, "last_bootstrapped_at" TEXT, + "last_status_check" TEXT, + "last_status_output" TEXT, + "last_status_checked_at" TEXT, "created_at" TEXT, "updated_at" TEXT ); @@ -1783,3 +1813,6 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_0 INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_v5_create_servers_table', 316); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_v5_create_clusters_table', 317); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers', 318); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table', 319); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_17_172845_add_status_check_fields_to_v5_servers_table', 320); diff --git a/database/seeders/V5DevLimaSeeder.php b/database/seeders/V5DevLimaSeeder.php index 1151f8fb3..ea796622d 100644 --- a/database/seeders/V5DevLimaSeeder.php +++ b/database/seeders/V5DevLimaSeeder.php @@ -59,26 +59,32 @@ class V5DevLimaSeeder extends Seeder 'capabilities' => $capabilities, 'builder_enabled' => $builderEnabled, 'builder_capacity' => $builderCapacity, + 'wireguard_listen_port_override' => $server['wireguard_listen_port_override'], + 'wireguard_endpoint_override' => $server['wireguard_endpoint_override'], 'last_bootstrapped_at' => now(), ]); } } /** - * @return array + * @return array */ private function servers(): array { return [ [ 'name' => 'coold-dev', - 'host' => 'lima-coold-dev', - 'ssh_port' => 22, + 'host' => 'host.docker.internal', + 'ssh_port' => 60001, + 'wireguard_listen_port_override' => 51821, + 'wireguard_endpoint_override' => 'host.lima.internal:51821', ], [ 'name' => 'coold-dev-2', - 'host' => 'lima-coold-dev-2', - 'ssh_port' => 22, + 'host' => 'host.docker.internal', + 'ssh_port' => 60002, + 'wireguard_listen_port_override' => 51822, + 'wireguard_endpoint_override' => 'host.lima.internal:51822', ], ]; } diff --git a/dev/lima/coold.yaml b/dev/lima/coold.yaml index 4629d61cc..327d1589c 100644 --- a/dev/lima/coold.yaml +++ b/dev/lima/coold.yaml @@ -13,6 +13,9 @@ containerd: system: false user: false +ssh: + localPort: {{COOLIFY_COOLD_VM_SSH_PORT}} + images: - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" arch: "x86_64" @@ -58,4 +61,19 @@ provision: mkdir -p /etc/coolify /var/lib/coolify-dev chmod 755 /etc/coolify + coolify_test_public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd" + install -d -m 700 /root/.ssh + touch /root/.ssh/authorized_keys + grep -qxF "$coolify_test_public_key" /root/.ssh/authorized_keys || echo "$coolify_test_public_key" >>/root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + target_home="$(getent passwd 501 | cut -d: -f6 || true)" + if [ -n "$target_home" ] && [ -d "$target_home" ]; then + owner="$(stat -c '%u:%g' "$target_home")" + install -d -m 700 -o "${owner%:*}" -g "${owner#*:}" "$target_home/.ssh" + touch "$target_home/.ssh/authorized_keys" + grep -qxF "$coolify_test_public_key" "$target_home/.ssh/authorized_keys" || echo "$coolify_test_public_key" >>"$target_home/.ssh/authorized_keys" + chown "$owner" "$target_home/.ssh/authorized_keys" + chmod 600 "$target_home/.ssh/authorized_keys" + fi + echo "[coold-vm] Minimal provisioning complete. coolify bootstrap will install WireGuard, Podman, Corrosion, coold, and builder." diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 83dfad68d..6cfa90723 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,6 +19,7 @@ services: COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}" COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}" COOLIFY_CLI_VERSION: "${COOLIFY_CLI_VERSION:-nightly}" + COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}" COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}" PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: "${PUSHER_PORT:-}" diff --git a/resources/css/app.css b/resources/css/app.css index de92bf0c9..a4f42149d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -105,6 +105,14 @@ body { @apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400; } +@media (max-width: 767px) { + input, + textarea, + select { + font-size: 16px; + } +} + body { @apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden; } diff --git a/resources/css/v5/app.css b/resources/css/v5/app.css index 1f85d6958..e1637b694 100644 --- a/resources/css/v5/app.css +++ b/resources/css/v5/app.css @@ -152,6 +152,14 @@ @apply font-sans; } + @media (max-width: 767px) { + input, + textarea, + select { + font-size: 16px; + } + } + body { @apply bg-background text-foreground; min-height: 100vh; diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 26f5f9a4e..a564b710e 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -4,9 +4,11 @@ import type { FormEvent } from 'react'; import { AppNavbar } from '@/components/app-navbar'; import { Button } from '@/components/ui/button'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -14,17 +16,88 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { csrfToken } from '@/lib/csrf'; -import type { V5Cluster, V5DashboardProps } from '@/types'; +import { usePendingIds } from '@/lib/use-pending-ids'; +import type { V5Cluster, V5DashboardProps, V5Server } from '@/types'; type ClusterFormErrors = { name?: string[]; description?: string[]; + wireguard_interface?: string[]; + wireguard_management_pool?: string[]; + wireguard_listen_port?: string[]; + container_network_pool?: string[]; + container_network_prefix?: string[]; + namespaces?: string[]; + default_deny_containers?: string[]; + coold_version?: string[]; + corrosion_version?: string[]; + corrosion_gossip_port?: string[]; + corrosion_api_port?: string[]; + builder_enabled?: string[]; + builder_capacity?: string[]; + builder_cpu_quota?: string[]; + builder_memory_max?: string[]; + builder_timeout_secs?: string[]; }; type StoreClusterResponse = { cluster: V5Cluster; }; +type ServerFormErrors = { + name?: string[]; + host?: string[]; + ssh_user?: string[]; + ssh_port?: string[]; + private_key_id?: string[]; + node_address?: string[]; + builder_enabled?: string[]; + builder_capacity?: string[]; + builder_cpu_quota?: string[]; + wireguard_listen_port_override?: string[]; + wireguard_endpoint_override?: string[]; +}; + +type StoreServerResponse = { + cluster: V5Cluster; +}; + +type UpdateServerResponse = { + cluster: V5Cluster; +}; + +type CheckServerResponse = { + cluster: V5Cluster; +}; + +type DeleteServerResponse = { + cluster: V5Cluster; +}; + +type BootstrapServerResponse = { + cluster?: V5Cluster; + message?: string; +}; + +const clusterDefaults = { + wireguardInterface: 'wg0', + wireguardManagementPool: '100.64.0.0/16', + wireguardListenPort: '51820', + containerNetworkPool: '10.210.0.0/16', + containerNetworkPrefix: '24', + namespaces: 'default', + defaultDenyContainers: true, + cooldVersion: 'nightly', + corrosionVersion: 'v1.0.0', + corrosionGossipPort: '8787', + corrosionApiPort: '8080', + builderEnabled: true, + builderCapacity: '2', + builderCpuQuota: '200%', + builderMemoryMax: '2G', + builderTimeoutSecs: '1800', +}; + function formatDate(value: string | null): string { if (value === null) { return 'Never'; @@ -47,6 +120,7 @@ function normalizeCapabilities(capabilities: string[]): string { export default function Clusters({ flux, clusters = [], + privateKeys = [], projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null, @@ -55,9 +129,57 @@ export default function Clusters({ const [selectedClusterId, setSelectedClusterId] = useState(clusters[0]?.id ?? ''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); + const [wireguardInterface, setWireguardInterface] = useState(clusterDefaults.wireguardInterface); + const [wireguardManagementPool, setWireguardManagementPool] = useState(clusterDefaults.wireguardManagementPool); + const [wireguardListenPort, setWireguardListenPort] = useState(clusterDefaults.wireguardListenPort); + const [containerNetworkPool, setContainerNetworkPool] = useState(clusterDefaults.containerNetworkPool); + const [containerNetworkPrefix, setContainerNetworkPrefix] = useState(clusterDefaults.containerNetworkPrefix); + const [namespaces, setNamespaces] = useState(clusterDefaults.namespaces); + const [defaultDenyContainers, setDefaultDenyContainers] = useState(clusterDefaults.defaultDenyContainers); + const [cooldVersion, setCooldVersion] = useState(clusterDefaults.cooldVersion); + const [corrosionVersion, setCorrosionVersion] = useState(clusterDefaults.corrosionVersion); + const [corrosionGossipPort, setCorrosionGossipPort] = useState(clusterDefaults.corrosionGossipPort); + const [corrosionApiPort, setCorrosionApiPort] = useState(clusterDefaults.corrosionApiPort); + const [builderEnabled, setBuilderEnabled] = useState(clusterDefaults.builderEnabled); + const [builderCapacity, setBuilderCapacity] = useState(clusterDefaults.builderCapacity); + const [builderCpuQuota, setBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota); + const [builderMemoryMax, setBuilderMemoryMax] = useState(clusterDefaults.builderMemoryMax); + const [builderTimeoutSecs, setBuilderTimeoutSecs] = useState(clusterDefaults.builderTimeoutSecs); const [errors, setErrors] = useState({}); + const [serverName, setServerName] = useState(''); + const [serverHost, setServerHost] = useState(''); + const [serverSshUser, setServerSshUser] = useState('root'); + const [serverSshPort, setServerSshPort] = useState('22'); + const [selectedPrivateKeyId, setSelectedPrivateKeyId] = useState(''); + const [serverNodeAddress, setServerNodeAddress] = useState(''); + const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true); + const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2'); + const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota); + const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState(''); + const [wireguardEndpointOverride, setWireguardEndpointOverride] = useState(''); + const [serverErrors, setServerErrors] = useState({}); + const [editingServer, setEditingServer] = useState(null); + const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true); + const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2'); + const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota); + const [editServerErrors, setEditServerErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); + const [isServerSubmitting, setIsServerSubmitting] = useState(false); + const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false); + const checkingServers = usePendingIds(); + const bootstrappingServers = usePendingIds(); + const [bootstrapServerError, setBootstrapServerError] = useState(null); + const deletingServers = usePendingIds(); + const [isDeletingCluster, setIsDeletingCluster] = useState(false); + const [deleteClusterError, setDeleteClusterError] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [clusterPendingDelete, setClusterPendingDelete] = useState(null); + const [serverPendingDelete, setServerPendingDelete] = useState(null); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isAddServerDialogOpen, setIsAddServerDialogOpen] = useState(false); + const [isEditServerDialogOpen, setIsEditServerDialogOpen] = useState(false); + const [showAdvancedConfiguration, setShowAdvancedConfiguration] = useState(false); + const [showAdvancedServerConfiguration, setShowAdvancedServerConfiguration] = useState(false); const selectedCluster = useMemo( () => clusterList.find((cluster) => cluster.id === selectedClusterId) ?? clusterList[0] ?? null, @@ -80,11 +202,32 @@ export default function Clusters({ body: JSON.stringify({ name, description: description.trim() === '' ? null : description, + wireguard_interface: wireguardInterface, + wireguard_management_pool: wireguardManagementPool, + wireguard_listen_port: Number(wireguardListenPort), + container_network_pool: containerNetworkPool, + container_network_prefix: Number(containerNetworkPrefix), + namespaces: namespaces + .split(',') + .map((namespace) => namespace.trim()) + .filter(Boolean), + default_deny_containers: defaultDenyContainers, + coold_version: cooldVersion, + corrosion_version: corrosionVersion, + corrosion_gossip_port: Number(corrosionGossipPort), + corrosion_api_port: Number(corrosionApiPort), + builder_enabled: builderEnabled, + builder_capacity: Number(builderCapacity), + builder_cpu_quota: builderCpuQuota, + builder_memory_max: builderMemoryMax, + builder_timeout_secs: Number(builderTimeoutSecs), }), }); if (response.status === 422) { - const payload = (await response.json()) as { errors?: ClusterFormErrors }; + const payload = (await response.json()) as { + errors?: ClusterFormErrors; + }; setErrors(payload.errors ?? {}); setIsSubmitting(false); @@ -101,21 +244,367 @@ export default function Clusters({ } const payload = (await response.json()) as StoreClusterResponse; - const nextClusters = [...clusterList, payload.cluster].sort((first, second) => first.name.localeCompare(second.name)); + const nextClusters = [...clusterList, payload.cluster].sort((first, second) => + first.name.localeCompare(second.name), + ); setClusterList(nextClusters); setSelectedClusterId(payload.cluster.id); setName(''); setDescription(''); + resetAdvancedConfiguration(); setIsCreateDialogOpen(false); setIsSubmitting(false); } + async function createServer(event: FormEvent): Promise { + event.preventDefault(); + + if (!selectedCluster) { + return; + } + + setIsServerSubmitting(true); + setServerErrors({}); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + name: serverName, + host: serverHost, + ssh_user: serverSshUser, + ssh_port: Number(serverSshPort), + private_key_id: selectedPrivateKeyId === '' ? null : Number(selectedPrivateKeyId), + node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress, + builder_enabled: serverBuilderEnabled, + builder_capacity: Number(serverBuilderCapacity), + builder_cpu_quota: serverBuilderCpuQuota, + wireguard_listen_port_override: + wireguardListenPortOverride.trim() === '' ? null : Number(wireguardListenPortOverride), + wireguard_endpoint_override: wireguardEndpointOverride.trim() === '' ? null : wireguardEndpointOverride, + }), + }); + + if (response.status === 422) { + const payload = (await response.json()) as { + errors?: ServerFormErrors; + }; + setServerErrors(payload.errors ?? {}); + setIsServerSubmitting(false); + + return; + } + + if (!response.ok) { + setServerErrors({ + name: ['Unable to add this server. Please try again.'], + }); + setIsServerSubmitting(false); + + return; + } + + const payload = (await response.json()) as StoreServerResponse; + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + ); + resetServerForm(); + setIsAddServerDialogOpen(false); + setIsServerSubmitting(false); + } + + async function updateServer(event: FormEvent): Promise { + event.preventDefault(); + + if (!selectedCluster || !editingServer) { + return; + } + + setIsServerUpdateSubmitting(true); + setEditServerErrors({}); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + builder_enabled: editServerBuilderEnabled, + builder_capacity: Number(editServerBuilderCapacity), + builder_cpu_quota: editServerBuilderCpuQuota, + }), + }); + + if (response.status === 422) { + const payload = (await response.json()) as { + errors?: ServerFormErrors; + }; + setEditServerErrors(payload.errors ?? {}); + setIsServerUpdateSubmitting(false); + + return; + } + + if (!response.ok) { + setEditServerErrors({ + builder_capacity: ['Unable to update this server. Please try again.'], + }); + setIsServerUpdateSubmitting(false); + + return; + } + + const payload = (await response.json()) as UpdateServerResponse; + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + ); + resetEditServerForm(); + setIsEditServerDialogOpen(false); + setIsServerUpdateSubmitting(false); + } + + async function checkServer(server: V5Server): Promise { + if (!selectedCluster) { + return; + } + + checkingServers.start(server.id); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + + if (response.ok) { + const payload = (await response.json()) as CheckServerResponse; + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + ); + } + + checkingServers.finish(server.id); + } + + async function bootstrapServer(server: V5Server): Promise { + if (!selectedCluster) { + return; + } + + bootstrappingServers.start(server.id); + setBootstrapServerError(null); + + const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + const payload = (await response.json()) as BootstrapServerResponse; + + if (payload.cluster) { + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster?.id ? payload.cluster : cluster)), + ); + } + + if (!response.ok) { + setBootstrapServerError(payload.message ?? 'Unable to bootstrap this server. Check the CLI state output.'); + } + + bootstrappingServers.finish(server.id); + } + + function openDeleteClusterDialog(): void { + if (!selectedCluster || selectedCluster.serversCount !== 0) { + setDeleteClusterError('Only empty clusters can be deleted.'); + + return; + } + + setDeleteClusterError(null); + setClusterPendingDelete(selectedCluster); + setServerPendingDelete(null); + setIsDeleteDialogOpen(true); + } + + function openDeleteServerDialog(server: V5Server): void { + if (!selectedCluster || server.lastBootstrappedAt !== null) { + return; + } + + setDeleteClusterError(null); + setClusterPendingDelete(selectedCluster); + setServerPendingDelete(server); + setIsDeleteDialogOpen(true); + } + + async function deleteUnbootstrappedServer(cluster: V5Cluster, server: V5Server): Promise { + if (server.lastBootstrappedAt !== null) { + return; + } + + deletingServers.start(server.id); + + const response = await fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + + if (response.ok) { + const payload = (await response.json()) as DeleteServerResponse; + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), + ); + } + + deletingServers.finish(server.id); + setIsDeleteDialogOpen(false); + setClusterPendingDelete(null); + setServerPendingDelete(null); + } + + function openEditServerDialog(server: V5Server): void { + setEditingServer(server); + setEditServerBuilderEnabled(server.builderEnabled); + setEditServerBuilderCapacity(String(server.builderCapacity)); + setEditServerBuilderCpuQuota(server.builderCpuQuota); + setEditServerErrors({}); + setIsEditServerDialogOpen(true); + } + + async function deleteCluster(cluster: V5Cluster): Promise { + if (cluster.serversCount !== 0) { + setDeleteClusterError('Only empty clusters can be deleted.'); + + return; + } + + setIsDeletingCluster(true); + setDeleteClusterError(null); + + const response = await fetch(`/v5/clusters/${cluster.id}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + + if (response.status === 422) { + const payload = (await response.json()) as { message?: string }; + setDeleteClusterError(payload.message ?? 'Only empty clusters can be deleted.'); + setIsDeletingCluster(false); + + return; + } + + if (!response.ok) { + setDeleteClusterError('Unable to delete this cluster. Please try again.'); + setIsDeletingCluster(false); + + return; + } + + const nextClusters = clusterList.filter((cluster) => cluster.id !== selectedCluster.id); + + setClusterList(nextClusters); + setSelectedClusterId(nextClusters[0]?.id ?? ''); + setIsDeletingCluster(false); + setIsDeleteDialogOpen(false); + setClusterPendingDelete(null); + setServerPendingDelete(null); + } + + async function confirmDelete(): Promise { + if (!clusterPendingDelete) { + return; + } + + if (serverPendingDelete) { + await deleteUnbootstrappedServer(clusterPendingDelete, serverPendingDelete); + + return; + } + + await deleteCluster(clusterPendingDelete); + } + + function resetAdvancedConfiguration(): void { + setWireguardInterface(clusterDefaults.wireguardInterface); + setWireguardManagementPool(clusterDefaults.wireguardManagementPool); + setWireguardListenPort(clusterDefaults.wireguardListenPort); + setContainerNetworkPool(clusterDefaults.containerNetworkPool); + setContainerNetworkPrefix(clusterDefaults.containerNetworkPrefix); + setNamespaces(clusterDefaults.namespaces); + setDefaultDenyContainers(clusterDefaults.defaultDenyContainers); + setCooldVersion(clusterDefaults.cooldVersion); + setCorrosionVersion(clusterDefaults.corrosionVersion); + setCorrosionGossipPort(clusterDefaults.corrosionGossipPort); + setCorrosionApiPort(clusterDefaults.corrosionApiPort); + setBuilderEnabled(clusterDefaults.builderEnabled); + setBuilderCapacity(clusterDefaults.builderCapacity); + setBuilderCpuQuota(clusterDefaults.builderCpuQuota); + setBuilderMemoryMax(clusterDefaults.builderMemoryMax); + setBuilderTimeoutSecs(clusterDefaults.builderTimeoutSecs); + setShowAdvancedConfiguration(false); + } + + function resetServerForm(): void { + setServerName(''); + setServerHost(''); + setServerSshUser('root'); + setServerSshPort('22'); + setSelectedPrivateKeyId(''); + setServerNodeAddress(''); + setServerBuilderEnabled(selectedCluster?.builderEnabled ?? true); + setServerBuilderCapacity(String(selectedCluster?.builderCapacity ?? 2)); + setServerBuilderCpuQuota(selectedCluster?.builderCpuQuota ?? clusterDefaults.builderCpuQuota); + setWireguardListenPortOverride(''); + setWireguardEndpointOverride(''); + setServerErrors({}); + setShowAdvancedServerConfiguration(false); + } + + function resetEditServerForm(): void { + setEditingServer(null); + setEditServerBuilderEnabled(true); + setEditServerBuilderCapacity('2'); + setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota); + setEditServerErrors({}); + } + return ( <> -
+
-
-
-