From 24bbfe07c6fbc22978db8f443af22695ee249e69 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:44:42 +0200 Subject: [PATCH] feat(v5): add server bootstrap job with realtime cluster broadcasting - Add V5BootstrapServerJob: SSH into new servers, install coold/WireGuard, track bootstrap progress via last_bootstrap_action/status/output fields - Add V5ClusterUpdated event: broadcasts full cluster+servers payload to team private channel on bootstrap state changes - Add V5RealtimeTestEvent + RealtimeTest page for WebSocket connectivity testing - Consolidate 3 dropped migrations into base cluster/server migrations: WireGuard config, builder CPU quota, status check fields, uuid - Auto-generate uuid on V5Model create via Schema::hasColumn guard - Expose currentTeam.id in Inertia shared props - Add showCluster, realtimeTest, broadcastRealtimeTest controller endpoints - Add dropdown-menu UI component --- app/Events/V5ClusterUpdated.php | 104 +++ app/Events/V5RealtimeTestEvent.php | 45 + .../Controllers/V5/DashboardController.php | 221 ++--- .../Middleware/V5/HandleInertiaRequests.php | 3 + app/Jobs/V5BootstrapServerJob.php | 447 ++++++++++ app/Models/V5/Server.php | 6 + app/Models/V5/V5Model.php | 15 +- ..._06_16_130649_v5_create_clusters_table.php | 20 + ...6_06_16_130650_v5_create_servers_table.php | 15 + ..._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 | 13 +- dev/coold-dev.md | 10 +- resources/css/v5/app.css | 6 + resources/js/v5/Pages/Clusters.tsx | 644 ++++++++++----- resources/js/v5/Pages/RealtimeTest.tsx | 199 +++++ resources/js/v5/components/app-navbar.tsx | 11 +- resources/js/v5/components/ui/button.tsx | 8 +- resources/js/v5/components/ui/dialog.tsx | 2 +- .../js/v5/components/ui/dropdown-menu.tsx | 266 ++++++ resources/js/v5/components/ui/input.tsx | 2 +- resources/js/v5/components/ui/select.tsx | 4 +- resources/js/v5/components/ui/textarea.tsx | 2 +- resources/js/v5/types.ts | 11 +- resources/views/v5/app.blade.php | 22 + routes/v5.php | 3 + scripts/coold-vm.sh | 87 +- scripts/dev.sh | 167 +++- .../DevScriptFirewallDelegationTest.php | 43 + tests/Feature/V5/AppNavbarTest.php | 17 + tests/Feature/V5/ButtonVariantTest.php | 32 + .../V5/ClusterCapabilitiesSummaryTest.php | 9 + .../Feature/V5/ClustersControlHeightTest.php | 11 + tests/Feature/V5/DashboardTest.php | 778 +++++++++++++++--- 35 files changed, 2744 insertions(+), 639 deletions(-) create mode 100644 app/Events/V5ClusterUpdated.php create mode 100644 app/Events/V5RealtimeTestEvent.php create mode 100644 app/Jobs/V5BootstrapServerJob.php delete mode 100644 database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php delete mode 100644 database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php delete mode 100644 database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php create mode 100644 resources/js/v5/Pages/RealtimeTest.tsx create mode 100644 resources/js/v5/components/ui/dropdown-menu.tsx create mode 100644 tests/Feature/V5/AppNavbarTest.php create mode 100644 tests/Feature/V5/ButtonVariantTest.php create mode 100644 tests/Feature/V5/ClusterCapabilitiesSummaryTest.php create mode 100644 tests/Feature/V5/ClustersControlHeightTest.php diff --git a/app/Events/V5ClusterUpdated.php b/app/Events/V5ClusterUpdated.php new file mode 100644 index 000000000..a820ba6d4 --- /dev/null +++ b/app/Events/V5ClusterUpdated.php @@ -0,0 +1,104 @@ +teamId}"), + ]; + } + + public function broadcastAs(): string + { + return 'v5.cluster.updated'; + } + + /** + * @return array{cluster: array|null} + */ + public function broadcastWith(): array + { + $cluster = V5Cluster::query() + ->where('team_id', $this->teamId) + ->with(['servers' => fn ($query) => $query + ->with('privateKey') + ->orderBy('name')]) + ->withCount('servers') + ->find($this->clusterId); + + return [ + 'cluster' => $cluster instanceof V5Cluster ? $this->serializeCluster($cluster) : null, + ]; + } + + /** + * @return array + */ + private function serializeCluster(V5Cluster $cluster): array + { + return [ + '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, + 'status' => $server->status, + 'capabilities' => $server->capabilities ?? [], + 'builderEnabled' => $server->builder_enabled, + 'builderCapacity' => $server->builder_capacity, + 'builderCpuQuota' => $server->builder_cpu_quota, + 'uuid' => $server->uuid, + '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(), + 'lastBootstrapAction' => $server->last_bootstrap_action, + 'lastBootstrapStatus' => $server->last_bootstrap_status, + 'lastBootstrapOutput' => $server->last_bootstrap_output, + 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), + ])->all(), + ]; + } +} diff --git a/app/Events/V5RealtimeTestEvent.php b/app/Events/V5RealtimeTestEvent.php new file mode 100644 index 000000000..1a446b301 --- /dev/null +++ b/app/Events/V5RealtimeTestEvent.php @@ -0,0 +1,45 @@ +sentAt = now()->toJSON(); + } + + public function broadcastOn(): array + { + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } + + public function broadcastAs(): string + { + return 'v5.realtime.test'; + } + + /** + * @return array{message: string, teamId: int, sentAt: string} + */ + public function broadcastWith(): array + { + return [ + 'message' => $this->message, + 'teamId' => $this->teamId, + 'sentAt' => $this->sentAt, + ]; + } +} diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index ef4a9a376..904e1e9d0 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -2,7 +2,10 @@ namespace App\Http\Controllers\V5; +use App\Events\V5ClusterUpdated; +use App\Events\V5RealtimeTestEvent; use App\Http\Controllers\Controller; +use App\Jobs\V5BootstrapServerJob; use App\Models\Environment; use App\Models\PrivateKey; use App\Models\Project; @@ -55,6 +58,56 @@ class DashboardController extends Controller ]); } + public function showCluster(Request $request, V5Cluster $cluster): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) { + abort(404); + } + + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + ]); + } + + public function realtimeTest(Request $request): Response + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team) { + abort(403); + } + + return Inertia::render('RealtimeTest', [ + 'currentTeam' => [ + 'id' => $currentTeam->id, + ], + ]); + } + + public function broadcastRealtimeTest(Request $request): JsonResponse + { + $currentTeam = $request->attributes->get('v5.currentTeam'); + + if (! $currentTeam instanceof Team) { + abort(403); + } + + $validated = $request->validate([ + 'message' => ['nullable', 'string', 'max:255'], + ]); + + V5RealtimeTestEvent::dispatch( + $currentTeam->id, + $validated['message'] ?? 'Manual v5 realtime test' + ); + + return response()->json([ + 'message' => 'Realtime test event broadcasted.', + ], 202); + } + public function updateSelection(Request $request): \Illuminate\Http\Response { $currentTeam = $request->attributes->get('v5.currentTeam'); @@ -115,7 +168,9 @@ class DashboardController extends Controller '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_capacity' => $this->builderCapacityRules( + $this->requestedBuilderEnabled($request, true) + ), 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], 'builder_memory_max' => ['sometimes', 'string', 'max:32'], 'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'], @@ -159,12 +214,18 @@ class DashboardController extends Controller ], 409); } + if (in_array($server->last_bootstrap_status, ['queued', 'running'], true)) { + return response()->json([ + 'cluster' => $this->freshSerializedCluster($cluster), + 'message' => 'Bootstrap is already queued or running for this server.', + ], 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) @@ -177,75 +238,20 @@ class DashboardController extends Controller ], 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(), + $server->update([ + 'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend', + 'last_bootstrap_status' => 'queued', + 'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.", + 'last_bootstrap_ran_at' => now(), ]); - $keyDirectory = storage_path('app/ssh/keys'); - if (! is_dir($keyDirectory)) { - mkdir($keyDirectory, 0700, true); - } + V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id); + V5BootstrapServerJob::dispatch($cluster->id, $server->id); - $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 = [ + return response()->json([ 'cluster' => $this->freshSerializedCluster($cluster), - ]; - - if (! $successful) { - $payload['message'] = $cluster->last_cli_summary; - } - - return response()->json($payload, $successful ? 200 : 500); + 'message' => 'Bootstrap queued.', + ], 202); } public function storeServer(Request $request, V5Cluster $cluster): JsonResponse @@ -275,7 +281,9 @@ class DashboardController extends Controller ], 'node_address' => ['nullable', 'string', 'max:255'], 'builder_enabled' => ['sometimes', 'boolean'], - 'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'], + 'builder_capacity' => $this->builderCapacityRules( + $this->requestedBuilderEnabled($request, $cluster->builder_enabled) + ), 'builder_cpu_quota' => ['sometimes', 'string', 'max:32'], 'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'], 'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'], @@ -295,10 +303,10 @@ class DashboardController extends Controller 'ssh_user' => $validated['ssh_user'], 'ssh_port' => $validated['ssh_port'], 'private_key_id' => $validated['private_key_id'] ?? null, - 'status' => 'pending', + 'status' => 'added', 'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'], 'builder_enabled' => $builderEnabled, - 'builder_capacity' => $builderEnabled ? $builderCapacity : 0, + 'builder_capacity' => $builderCapacity, 'builder_cpu_quota' => $builderCpuQuota, 'node_address' => $validated['node_address'] ?? $validated['host'], 'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'], @@ -330,7 +338,10 @@ class DashboardController extends Controller $validated = $request->validate([ 'builder_enabled' => ['required', 'boolean'], - 'builder_capacity' => ['required', 'integer', 'min:0', 'max:1000'], + 'builder_capacity' => $this->builderCapacityRules( + $request->boolean('builder_enabled'), + required: true + ), 'builder_cpu_quota' => ['required', 'string', 'max:32'], ]); @@ -346,7 +357,7 @@ class DashboardController extends Controller $server->update([ 'capabilities' => $capabilities, 'builder_enabled' => $builderEnabled, - 'builder_capacity' => $builderEnabled ? (int) $validated['builder_capacity'] : 0, + 'builder_capacity' => (int) $validated['builder_capacity'], 'builder_cpu_quota' => $validated['builder_cpu_quota'], ]); @@ -374,14 +385,10 @@ class DashboardController extends Controller } 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), + 'status' => 'failed', + 'output' => 'No private key is attached to this server.', + 'checkedAt' => now()->toJSON(), ]); } @@ -392,14 +399,10 @@ class DashboardController extends Controller $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), + 'status' => 'failed', + 'output' => 'Could not create a temporary SSH key file.', + 'checkedAt' => now()->toJSON(), ]); } @@ -440,14 +443,10 @@ class DashboardController extends Controller @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), + 'status' => $status, + 'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(), + 'checkedAt' => now()->toJSON(), ]); } @@ -464,12 +463,6 @@ class DashboardController extends Controller abort(404); } - if ($server->last_bootstrapped_at !== null) { - return response()->json([ - 'message' => 'Only unbootstrapped servers can be deleted.', - ], 409); - } - $server->delete(); return response()->json([ @@ -800,6 +793,7 @@ class DashboardController extends Controller 'builderEnabled' => $server->builder_enabled, 'builderCapacity' => $server->builder_capacity, 'builderCpuQuota' => $server->builder_cpu_quota, + 'uuid' => $server->uuid, 'nodeAddress' => $server->node_address, 'wireguardListenPortOverride' => $server->wireguard_listen_port_override, 'wireguardEndpointOverride' => $server->wireguard_endpoint_override, @@ -808,9 +802,10 @@ class DashboardController extends Controller '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(), + 'lastBootstrapAction' => $server->last_bootstrap_action, + 'lastBootstrapStatus' => $server->last_bootstrap_status, + 'lastBootstrapOutput' => $server->last_bootstrap_output, + 'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(), ])->all(), ]; } @@ -875,6 +870,28 @@ class DashboardController extends Controller }; } + /** + * @return array + */ + private function builderCapacityRules(bool $builderEnabled, bool $required = false): array + { + return [ + $required ? 'required' : 'sometimes', + 'integer', + $builderEnabled ? 'min:1' : 'min:0', + 'max:1000', + ]; + } + + private function requestedBuilderEnabled(Request $request, bool $default): bool + { + if (! $request->has('builder_enabled')) { + return $default; + } + + return $request->boolean('builder_enabled'); + } + /** * @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/Http/Middleware/V5/HandleInertiaRequests.php b/app/Http/Middleware/V5/HandleInertiaRequests.php index 2772c9c2e..8951e161f 100644 --- a/app/Http/Middleware/V5/HandleInertiaRequests.php +++ b/app/Http/Middleware/V5/HandleInertiaRequests.php @@ -20,6 +20,9 @@ class HandleInertiaRequests extends Middleware 'email' => $request->user()->email, ] : null, ], + 'currentTeam' => $request->attributes->get('v5.currentTeam') ? [ + 'id' => $request->attributes->get('v5.currentTeam')->id, + ] : null, ]; } } diff --git a/app/Jobs/V5BootstrapServerJob.php b/app/Jobs/V5BootstrapServerJob.php new file mode 100644 index 000000000..3773a34fd --- /dev/null +++ b/app/Jobs/V5BootstrapServerJob.php @@ -0,0 +1,447 @@ + + */ + public function middleware(): array + { + return [(new WithoutOverlapping("v5-bootstrap-server-{$this->serverId}"))->expireAfter(7200)->dontRelease()]; + } + + public function handle(): void + { + $cluster = V5Cluster::query()->findOrFail($this->clusterId); + $server = V5Server::query()->with('privateKey')->findOrFail($this->serverId); + + if ($server->cluster_id !== $cluster->id || $server->last_bootstrapped_at !== null) { + return; + } + + $installedServers = $cluster->servers() + ->with('privateKey') + ->whereNotNull('last_bootstrapped_at') + ->orderBy('name') + ->get(); + $action = $installedServers->isEmpty() ? 'bootstrap' : 'extend'; + $servers = $installedServers->toBase() + ->push($server) + ->unique('id') + ->values(); + + if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) { + $this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.'); + + return; + } + + $server->update([ + 'last_bootstrap_action' => $action, + 'last_bootstrap_status' => 'running', + 'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...", + 'last_bootstrap_ran_at' => now(), + ]); + $this->broadcastClusterUpdated($server); + + $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)) { + $this->markFailed($server, $action, 'Could not create a temporary SSH configuration directory.'); + + return; + } + + try { + $sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory); + $existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation); + + if (($existingBootstrap['cluster_id'] ?? null) !== null) { + if ((string) $existingBootstrap['cluster_id'] !== (string) $cluster->id) { + $this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.'); + + return; + } + + $this->adoptExistingBootstrap($server, $existingBootstrap); + + return; + } + + $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(); + + $server->update([ + 'last_bootstrap_action' => $action, + 'last_bootstrap_status' => $successful ? 'succeeded' : 'failed', + 'last_bootstrap_output' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(), + 'last_bootstrap_ran_at' => now(), + ]); + $this->broadcastClusterUpdated($server); + + if (! $successful) { + return; + } + + $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(), + ]); + $this->broadcastClusterUpdated($server); + + $this->writeBootstrapMarker($cluster, $server, $sshConfigLocation); + } catch (\Throwable $e) { + $this->markFailed($server, $action, $e->getMessage()); + } finally { + $this->deleteDirectory($tempDirectory); + } + } + + public function failed(?\Throwable $exception): void + { + $server = V5Server::query()->find($this->serverId); + + if (! $server instanceof V5Server) { + return; + } + + $this->markFailed($server, $server->last_bootstrap_action ?? 'bootstrap', $exception?->getMessage() ?? 'Bootstrap job failed.'); + + Log::warning('V5 server bootstrap job failed', [ + 'server_id' => $this->serverId, + 'cluster_id' => $this->clusterId, + 'exception' => $exception?->getMessage(), + ]); + } + + private function markFailed(V5Server $server, string $action, string $output): void + { + $server->update([ + 'last_bootstrap_action' => $action, + 'last_bootstrap_status' => 'failed', + 'last_bootstrap_output' => str($output)->limit(20000)->toString(), + 'last_bootstrap_ran_at' => now(), + ]); + $this->broadcastClusterUpdated($server); + } + + private function broadcastClusterUpdated(V5Server $server): void + { + V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id); + } + + 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->uuid ?: $server->id); + } + + /** + * @return array + */ + private function detectExistingBootstrap(V5Server $server, string $sshConfigLocation): array + { + $result = Process::timeout(15)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $this->bootstrapNode($server), + 'if [ -f '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' ]; then cat '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).'; fi', + ]); + + if (! $result->successful()) { + return []; + } + + $output = trim($result->output()); + + if ($output === '') { + return []; + } + + try { + $decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $marker + */ + private function adoptExistingBootstrap(V5Server $server, array $marker): void + { + $serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null; + $updates = [ + 'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip, + 'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key, + 'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets, + 'status' => 'installed', + 'last_bootstrap_status' => 'succeeded', + 'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.', + 'last_bootstrap_ran_at' => now(), + 'last_bootstrapped_at' => now(), + ]; + + if ($serverUuid !== null && ! V5Server::query()->where('uuid', $serverUuid)->whereKeyNot($server->id)->exists()) { + $updates['uuid'] = $serverUuid; + } + + $server->update($updates); + $this->broadcastClusterUpdated($server); + } + + private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation): void + { + $payload = base64_encode(json_encode([ + 'cluster_id' => $cluster->id, + 'server_uuid' => $server->uuid, + 'wireguard_management_ip' => $server->wireguard_management_ip, + 'wireguard_public_key' => $server->wireguard_public_key, + 'container_subnets' => $server->container_subnets ?? [], + ], JSON_THROW_ON_ERROR)); + + Process::timeout(15)->run([ + 'ssh', + '-F', + $sshConfigLocation, + $this->bootstrapNode($server), + "payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi', + ]); + } + + /** + * @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(','); + } +} diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index 6b19103d6..7aa13b9de 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -12,6 +12,7 @@ class Server extends V5Model protected $table = 'v5_servers'; protected $fillable = [ + 'uuid', 'team_id', 'cluster_id', 'created_by_user_id', @@ -32,6 +33,10 @@ class Server extends V5Model 'wireguard_public_key', 'container_subnets', 'last_bootstrapped_at', + 'last_bootstrap_action', + 'last_bootstrap_status', + 'last_bootstrap_output', + 'last_bootstrap_ran_at', 'last_status_check', 'last_status_output', 'last_status_checked_at', @@ -44,6 +49,7 @@ class Server extends V5Model 'builder_enabled' => 'boolean', 'container_subnets' => 'array', 'last_bootstrapped_at' => 'datetime', + 'last_bootstrap_ran_at' => 'datetime', 'last_status_checked_at' => 'datetime', ]; } diff --git a/app/Models/V5/V5Model.php b/app/Models/V5/V5Model.php index 9907e827d..4180ad3e3 100644 --- a/app/Models/V5/V5Model.php +++ b/app/Models/V5/V5Model.php @@ -3,8 +3,21 @@ namespace App\Models\V5; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Schema; abstract class V5Model extends Model { - // + protected static function boot(): void + { + parent::boot(); + + static::creating(function (Model $model): void { + if ( + Schema::hasColumn($model->getTable(), 'uuid') + && ! $model->getAttribute('uuid') + ) { + $model->setAttribute('uuid', new_public_id()); + } + }); + } } diff --git a/database/migrations/2026_06_16_130649_v5_create_clusters_table.php b/database/migrations/2026_06_16_130649_v5_create_clusters_table.php index afc820906..fcbabc813 100644 --- a/database/migrations/2026_06_16_130649_v5_create_clusters_table.php +++ b/database/migrations/2026_06_16_130649_v5_create_clusters_table.php @@ -17,6 +17,26 @@ return new class extends Migration $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); $table->string('name'); $table->text('description')->nullable(); + $table->string('wireguard_interface')->default('wg0'); + $table->string('wireguard_management_pool')->default('100.64.0.0/16'); + $table->unsignedInteger('wireguard_listen_port')->default(51820); + $table->string('container_network_pool')->default('10.210.0.0/16'); + $table->unsignedTinyInteger('container_network_prefix')->default(24); + $table->json('namespaces')->nullable(); + $table->boolean('default_deny_containers')->default(true); + $table->string('coold_version')->default('nightly'); + $table->string('corrosion_version')->default('v1.0.0'); + $table->unsignedInteger('corrosion_gossip_port')->default(8787); + $table->unsignedInteger('corrosion_api_port')->default(8080); + $table->boolean('builder_enabled')->default(true); + $table->unsignedInteger('builder_capacity')->default(2); + $table->string('builder_cpu_quota')->default('200%'); + $table->string('builder_memory_max')->default('2G'); + $table->unsignedInteger('builder_timeout_secs')->default(1800); + $table->string('last_cli_action')->nullable(); + $table->string('last_cli_status')->nullable(); + $table->text('last_cli_summary')->nullable(); + $table->timestamp('last_cli_ran_at')->nullable(); $table->timestamps(); $table->unique(['team_id', 'name']); diff --git a/database/migrations/2026_06_16_130650_v5_create_servers_table.php b/database/migrations/2026_06_16_130650_v5_create_servers_table.php index 29fbaf91d..6e3579b3b 100644 --- a/database/migrations/2026_06_16_130650_v5_create_servers_table.php +++ b/database/migrations/2026_06_16_130650_v5_create_servers_table.php @@ -13,6 +13,7 @@ return new class extends Migration { Schema::create('v5_servers', function (Blueprint $table) { $table->id(); + $table->string('uuid')->nullable()->unique(); $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); $table->foreignId('cluster_id')->nullable()->constrained('v5_clusters')->nullOnDelete(); $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); @@ -25,7 +26,21 @@ return new class extends Migration $table->json('capabilities')->nullable(); $table->boolean('builder_enabled')->default(false); $table->unsignedInteger('builder_capacity')->default(0); + $table->string('builder_cpu_quota')->default('200%'); + $table->string('node_address')->nullable(); + $table->unsignedInteger('wireguard_listen_port_override')->nullable(); + $table->string('wireguard_endpoint_override')->nullable(); + $table->string('wireguard_management_ip')->nullable(); + $table->string('wireguard_public_key')->nullable(); + $table->json('container_subnets')->nullable(); $table->timestamp('last_bootstrapped_at')->nullable(); + $table->string('last_bootstrap_action')->nullable(); + $table->string('last_bootstrap_status')->nullable(); + $table->text('last_bootstrap_output')->nullable(); + $table->timestamp('last_bootstrap_ran_at')->nullable(); + $table->string('last_status_check')->nullable(); + $table->text('last_status_output')->nullable(); + $table->timestamp('last_status_checked_at')->nullable(); $table->timestamps(); $table->unique(['team_id', 'host', 'ssh_port']); 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 deleted file mode 100644 index 8ac987e00..000000000 --- a/database/migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index a07d18d8a..000000000 --- a/database/migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 76dd8f2c2..000000000 --- a/database/migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php +++ /dev/null @@ -1,34 +0,0 @@ -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 c2fde927e..dcc64d2f7 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -1353,6 +1353,7 @@ CREATE TABLE IF NOT EXISTS "v5_clusters" ( CREATE TABLE IF NOT EXISTS "v5_servers" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "uuid" TEXT, "team_id" INTEGER NOT NULL, "cluster_id" INTEGER, "created_by_user_id" INTEGER NOT NULL, @@ -1373,6 +1374,10 @@ CREATE TABLE IF NOT EXISTS "v5_servers" ( "wireguard_public_key" TEXT, "container_subnets" JSON, "last_bootstrapped_at" TEXT, + "last_bootstrap_action" TEXT, + "last_bootstrap_status" TEXT, + "last_bootstrap_output" TEXT, + "last_bootstrap_ran_at" TEXT, "last_status_check" TEXT, "last_status_output" TEXT, "last_status_checked_at" TEXT, @@ -1494,6 +1499,7 @@ CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_cha CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id); CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag); CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email); +CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid); CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id); -- Migration records @@ -1811,8 +1817,5 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1 INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); 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); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317); diff --git a/dev/coold-dev.md b/dev/coold-dev.md index a1f2d5036..8052059e2 100644 --- a/dev/coold-dev.md +++ b/dev/coold-dev.md @@ -4,7 +4,15 @@ This file documents the current local v5/coold dev setup in Coolify. ## Roles -- `scripts/dev.sh` owns local developer convenience. +- `scripts/dev.sh` is the main developer-facing entrypoint. Use it for normal + local workflows such as starting/stopping the stack, creating fresh dev state, + inspecting Corrosion, managing firewall allow rules, and running example + containers. +- `scripts/coold-vm.sh` is a lower-level Lima VM helper used by `scripts/dev.sh`. + It exists separately to keep VM lifecycle and guest setup details out of the + main dev orchestration script. Call it directly only when debugging or + operating an individual VM, for example `shell`, `status`, `logs-agent`, or + `delete`. - Lima VMs act like real deployment servers. - `coolify init bootstrap` owns host wiring: - WireGuard diff --git a/resources/css/v5/app.css b/resources/css/v5/app.css index e1637b694..519c1ecd4 100644 --- a/resources/css/v5/app.css +++ b/resources/css/v5/app.css @@ -58,6 +58,7 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --default-ring-color: var(--ring); } :root { @@ -140,6 +141,11 @@ ::backdrop, ::file-selector-button { @apply border-border outline-ring/50; + --tw-ring-offset-color: var(--background); + } + + :where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2; } html, diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index a564b710e..0f784bdc6 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -1,9 +1,18 @@ import { Head } from '@inertiajs/react'; -import { useMemo, useState } from 'react'; +import { DotsThreeIcon } from '@phosphor-icons/react'; +import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { AppNavbar } from '@/components/app-navbar'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -15,6 +24,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { csrfToken } from '@/lib/csrf'; import { usePendingIds } from '@/lib/use-pending-ids'; import type { V5Cluster, V5DashboardProps, V5Server } from '@/types'; @@ -67,18 +77,49 @@ type UpdateServerResponse = { }; type CheckServerResponse = { - cluster: V5Cluster; + status: string; + output: string; + checkedAt: string; }; type DeleteServerResponse = { cluster: V5Cluster; }; + type BootstrapServerResponse = { cluster?: V5Cluster; message?: string; }; +type ServerSshCheck = { + status: string; + output: string; + checkedAt: string; +}; + +type V5ClusterUpdatedEvent = { + cluster: V5Cluster | null; +}; + +type EchoChannel = { + listen: (event: string, callback: (payload: unknown) => void) => EchoChannel; + subscribed?: (callback: () => void) => EchoChannel; + error?: (callback: (error: unknown) => void) => EchoChannel; +}; + +type EchoClient = { + private: (channel: string) => EchoChannel; + leave?: (channel: string) => void; + leaveChannel?: (channel: string) => void; +}; + +declare global { + interface Window { + Echo?: EchoClient; + } +} + const clusterDefaults = { wireguardInterface: 'wg0', wireguardManagementPool: '100.64.0.0/16', @@ -109,16 +150,9 @@ function formatDate(value: string | null): string { }).format(new Date(value)); } -function normalizeCapabilities(capabilities: string[]): string { - if (capabilities.length === 0) { - return 'No capabilities'; - } - - return capabilities.join(', '); -} - export default function Clusters({ flux, + currentTeam = null, clusters = [], privateKeys = [], projects = [], @@ -167,6 +201,8 @@ export default function Clusters({ const [isServerSubmitting, setIsServerSubmitting] = useState(false); const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false); const checkingServers = usePendingIds(); + const [sshChecks, setSshChecks] = useState>({}); + const [visibleBootstrapLogs, setVisibleBootstrapLogs] = useState>({}); const bootstrappingServers = usePendingIds(); const [bootstrapServerError, setBootstrapServerError] = useState(null); const deletingServers = usePendingIds(); @@ -186,6 +222,106 @@ export default function Clusters({ [clusterList, selectedClusterId], ); + const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? []; + const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []; + const hasBootstrapInProgress = + selectedCluster?.servers.some((server) => ['queued', 'running'].includes(server.lastBootstrapStatus ?? '')) ?? false; + + useEffect(() => { + if (!currentTeam) { + return; + } + + let isCancelled = false; + let attempts = 0; + const channelName = `team.${currentTeam.id}`; + + const interval = window.setInterval(() => { + attempts += 1; + + if (!window.Echo) { + if (attempts === 1) { + console.debug('Waiting for window.Echo before subscribing to cluster updates'); + } + + if (attempts >= 20) { + window.clearInterval(interval); + } + + return; + } + + window.clearInterval(interval); + + if (isCancelled) { + return; + } + + const channel = window.Echo.private(channelName); + + channel.subscribed?.(() => console.debug(`Subscribed to private-${channelName} for cluster updates`)); + channel.error?.((error) => console.error(`Subscription error on private-${channelName}`, error)); + channel.listen('.v5.cluster.updated', (payload) => { + const event = payload as V5ClusterUpdatedEvent; + + if (!event.cluster) { + return; + } + + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)), + ); + }); + }, 500); + + return () => { + isCancelled = true; + window.clearInterval(interval); + window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`); + }; + }, [currentTeam]); + + useEffect(() => { + if (!selectedCluster || !hasBootstrapInProgress) { + return; + } + + let isCancelled = false; + + async function refreshCluster(): Promise { + if (!selectedCluster) { + return; + } + + const response = await fetch(`/v5/clusters/${selectedCluster.id}`, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok || isCancelled) { + return; + } + + const payload = (await response.json()) as { cluster?: V5Cluster }; + + if (payload.cluster) { + setClusterList((currentClusters) => + currentClusters.map((cluster) => (cluster.id === payload.cluster?.id ? payload.cluster : cluster)), + ); + } + } + + const interval = window.setInterval(() => void refreshCluster(), 3000); + + return () => { + isCancelled = true; + window.clearInterval(interval); + }; + }, [hasBootstrapInProgress, selectedCluster]); + async function createCluster(event: FormEvent): Promise { event.preventDefault(); setIsSubmitting(true); @@ -393,10 +529,10 @@ export default function Clusters({ if (response.ok) { const payload = (await response.json()) as CheckServerResponse; - - setClusterList((currentClusters) => - currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)), - ); + setSshChecks((currentChecks) => ({ + ...currentChecks, + [server.id]: payload, + })); } checkingServers.finish(server.id); @@ -428,7 +564,7 @@ export default function Clusters({ } if (!response.ok) { - setBootstrapServerError(payload.message ?? 'Unable to bootstrap this server. Check the CLI state output.'); + setBootstrapServerError(payload.message ?? 'Unable to queue bootstrap for this server.'); } bootstrappingServers.finish(server.id); @@ -448,7 +584,7 @@ export default function Clusters({ } function openDeleteServerDialog(server: V5Server): void { - if (!selectedCluster || server.lastBootstrappedAt !== null) { + if (!selectedCluster) { return; } @@ -458,11 +594,7 @@ export default function Clusters({ setIsDeleteDialogOpen(true); } - async function deleteUnbootstrappedServer(cluster: V5Cluster, server: V5Server): Promise { - if (server.lastBootstrappedAt !== null) { - return; - } - + async function deleteServer(cluster: V5Cluster, server: V5Server): Promise { deletingServers.start(server.id); const response = await fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`, { @@ -548,7 +680,7 @@ export default function Clusters({ } if (serverPendingDelete) { - await deleteUnbootstrappedServer(clusterPendingDelete, serverPendingDelete); + await deleteServer(clusterPendingDelete, serverPendingDelete); return; } @@ -600,6 +732,168 @@ export default function Clusters({ setEditServerErrors({}); } + + function renderServerCard(server: V5Server) { + const isCheckingServer = checkingServers.has(server.id); + const isBootstrapInProgress = ['queued', 'running'].includes(server.lastBootstrapStatus ?? ''); + const isBootstrappingServer = bootstrappingServers.has(server.id) || isBootstrapInProgress; + const isDeletingServer = deletingServers.has(server.id); + const isServerInitialized = server.lastBootstrappedAt !== null; + const latestSshCheck = sshChecks[server.id] ?? null; + const hasBootstrapLogs = server.lastBootstrapOutput !== null && server.lastBootstrapOutput.trim() !== ''; + const canShowBootstrapLogs = hasBootstrapLogs || isBootstrapInProgress; + const isBootstrapLogVisible = + isBootstrapInProgress || (canShowBootstrapLogs && (visibleBootstrapLogs[server.id] ?? false)); + + return ( +
+
+
+

{server.name}

+

{server.host}

+
+
+ {!isServerInitialized ? ( +
+ + Not initialized + + +
+ ) : null} + + + } + > + + + + + void checkServer(server)} + > + {isCheckingServer ? 'Checking...' : 'Check connection'} + + {canShowBootstrapLogs ? ( + + setVisibleBootstrapLogs((currentLogs) => ({ + ...currentLogs, + [server.id]: !isBootstrapLogVisible, + })) + } + > + {isBootstrapInProgress + ? 'Install logs shown' + : isBootstrapLogVisible + ? 'Hide install logs' + : 'Show install logs'} + + ) : null} + openEditServerDialog(server)}> + Edit server + + + + openDeleteServerDialog(server)} + > + {isDeletingServer ? 'Deleting...' : 'Delete server'} + + + +
+
+ +
+
+
Builder capacity
+
+ {server.builderEnabled ? server.builderCapacity : 'Disabled'} +
+
+ {server.builderEnabled ? ( +
+
Builder CPU quota
+
{server.builderCpuQuota}
+
+ ) : null} +
+
WireGuard IP
+
+ {server.wireguardManagementIp ?? 'Not assigned'} +
+
+
+
Server IP
+
{server.host}
+
+
+
Private key
+
+ {server.privateKeyName ?? 'No key'} +
+
+
+ + {latestSshCheck ? ( +
+
+ Latest SSH check: {latestSshCheck.status} + {formatDate(latestSshCheck.checkedAt)} +
+
+                            {latestSshCheck.output}
+                        
+
+ ) : null} + + {isBootstrapLogVisible ? ( +
+
+ + Install logs + {server.lastBootstrapStatus ? `: ${server.lastBootstrapStatus}` : ''} + + {formatDate(server.lastBootstrapRanAt)} +
+ {server.lastBootstrapOutput ? ( +
+                                {server.lastBootstrapOutput}
+                            
+ ) : ( +

No install logs captured yet.

+ )} +
+ ) : null} +
+ ); + } + return ( <> @@ -614,56 +908,69 @@ export default function Clusters({ />
-
- +
{selectedCluster ? ( @@ -686,7 +993,7 @@ export default function Clusters({ - {server.lastBootstrappedAt === null ? ( - - ) : null} - - {server.lastBootstrappedAt === null ? ( - - ) : null} - +
+ {notInitializedServers.length > 0 ? ( +
+
+

+ Not initialized servers +

+

+ Bootstrap these servers before using them for workloads. +

- -
-
-
- Builder capacity -
-
- {server.builderEnabled - ? server.builderCapacity - : 'Disabled'} -
-
-
-
- Builder CPU quota -
-
- {server.builderCpuQuota} -
-
-
-
WireGuard IP
-
- {server.wireguardManagementIp ?? 'Not assigned'} -
-
-
-
CLI node
-
- {server.nodeAddress ?? server.host} -
-
-
-
Private key
-
- {server.privateKeyName ?? 'No key'} -
-
-
-
- Last bootstrap -
-
- {formatDate(server.lastBootstrappedAt)} -
-
-
- -

- Capabilities: {normalizeCapabilities(server.capabilities)} -

- -
-
- - Latest SSH check - {server.lastStatusCheck - ? `: ${server.lastStatusCheck}` - : ''} - - - {server.lastStatusCheckedAt - ? formatDate(server.lastStatusCheckedAt) - : 'Never run'} - -
- {server.lastStatusOutput ? ( -
-                                                                    {server.lastStatusOutput}
-                                                                
- ) : ( -

- Run Check SSH to verify connectivity and capture - diagnostic output. -

- )} +
+ {notInitializedServers.map(renderServerCard)}
- - ); - })} +
+ ) : null} + + {initializedServers.length > 0 ? ( +
+
+

+ Servers +

+

+ Initialized servers currently assigned to this cluster. +

+
+
+ {initializedServers.map(renderServerCard)} +
+
+ ) : null}
)} @@ -1023,7 +1211,7 @@ export default function Clusters({ Confirm deletion {serverPendingDelete - ? `Delete unbootstrapped server ${serverPendingDelete.name}? This only removes it from this cluster.` + ? `Delete server ${serverPendingDelete.name}? This removes it from this cluster inventory so you can add it again later.` : `Delete cluster ${clusterPendingDelete?.name ?? ''}? This cannot be undone.`} @@ -1088,10 +1276,10 @@ export default function Clusters({ -
+
+
+
+
+ +
+
+

Event log

+ +
+
+                            {logs.length === 0 ? 'No logs yet.' : logs.join('\n\n')}
+                        
+
+
+ + + ); +} diff --git a/resources/js/v5/components/app-navbar.tsx b/resources/js/v5/components/app-navbar.tsx index 1b205f9ed..be1534073 100644 --- a/resources/js/v5/components/app-navbar.tsx +++ b/resources/js/v5/components/app-navbar.tsx @@ -26,8 +26,6 @@ function persistSelection(projectUuid: string, environmentUuid: string): void { type AppNavbarProps = V5DashboardProps; export function AppNavbar({ - flux, - clusters = [], projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null, @@ -86,7 +84,7 @@ export function AppNavbar({ className="flex shrink-0 items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" aria-label="Coolify dashboard" > - Coolify + Coolify
@@ -141,13 +139,6 @@ export function AppNavbar({ Clusters -
- Flux: {flux?.label ?? 'Unknown'} · {clusters.length} clusters -
- ) { } function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { - return
; + return
; } function DialogTitle({ className, ...props }: React.ComponentProps) { diff --git a/resources/js/v5/components/ui/dropdown-menu.tsx b/resources/js/v5/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..28077c0aa --- /dev/null +++ b/resources/js/v5/components/ui/dropdown-menu.tsx @@ -0,0 +1,266 @@ +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/resources/js/v5/components/ui/input.tsx b/resources/js/v5/components/ui/input.tsx index 1bda40f0c..c16cd13f5 100644 --- a/resources/js/v5/components/ui/input.tsx +++ b/resources/js/v5/components/ui/input.tsx @@ -7,7 +7,7 @@ function Input({ className, ...props }: React.ComponentProps<'input'>) { ) {