From 3ae87334bd2beb962513683b4d156aaf7b093f3b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:23:45 +0200 Subject: [PATCH] feat(v5): add WireGuard and coold/corrosion config to clusters/servers Add WireGuard networking fields (interface, management pool, listen port), container network pool, coold/corrosion versioning, and builder CPU quota to clusters and servers via new migrations and model fillables. Expose full cluster and server CRUD on the Clusters page with private key selection, pending state tracking, and new form primitives (Field, Input, Textarea). Add server status check fields and a lima test VM config for development. --- .dev/lima/coolify-naked-test.yaml | 32 + .dev/testing-host-key | 7 + .dev/testing-host-key.pub | 1 + app/Console/Commands/V5SyncDevLimaServers.php | 16 +- .../Controllers/V5/DashboardController.php | 719 ++++++++- app/Models/V5/Cluster.php | 76 + app/Models/V5/Server.php | 12 + ..._configuration_to_clusters_and_servers.php | 90 ++ ...add_builder_cpu_quota_to_servers_table.php | 36 + ...tatus_check_fields_to_v5_servers_table.php | 34 + database/schema/testing-schema.sql | 33 + database/seeders/V5DevLimaSeeder.php | 16 +- dev/lima/coold.yaml | 18 + docker-compose.dev.yml | 1 + resources/css/app.css | 8 + resources/css/v5/app.css | 8 + resources/js/v5/Pages/Clusters.tsx | 1348 ++++++++++++++++- resources/js/v5/components/ui/button.tsx | 1 + resources/js/v5/components/ui/dialog.tsx | 27 +- resources/js/v5/components/ui/field.tsx | 35 + resources/js/v5/components/ui/input.tsx | 18 + resources/js/v5/components/ui/textarea.tsx | 18 + resources/js/v5/lib/use-pending-ids.ts | 28 + resources/js/v5/types.ts | 38 +- routes/v5.php | 6 + scripts/coold-vm.sh | 3 + scripts/dev.sh | 48 +- .../DevScriptFirewallDelegationTest.php | 47 +- tests/Feature/V5/DashboardTest.php | 1228 ++++++++++++++- tests/Unit/MobileInputZoomPreventionTest.php | 27 + 30 files changed, 3876 insertions(+), 103 deletions(-) create mode 100644 .dev/lima/coolify-naked-test.yaml create mode 100644 .dev/testing-host-key create mode 100644 .dev/testing-host-key.pub create mode 100644 database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php create mode 100644 database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php create mode 100644 database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php create mode 100644 resources/js/v5/components/ui/field.tsx create mode 100644 resources/js/v5/components/ui/input.tsx create mode 100644 resources/js/v5/components/ui/textarea.tsx create mode 100644 resources/js/v5/lib/use-pending-ids.ts create mode 100644 tests/Unit/MobileInputZoomPreventionTest.php 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 ( <> -
+
-
-
-