diff --git a/app/Console/Commands/V5SyncDevLimaServers.php b/app/Console/Commands/V5SyncDevLimaServers.php new file mode 100644 index 000000000..b6f30ebef --- /dev/null +++ b/app/Console/Commands/V5SyncDevLimaServers.php @@ -0,0 +1,97 @@ +environment(['local', 'development', 'testing']) && ! $this->option('force')) { + $this->error('This command is intended for development only. Use --force to override.'); + + return self::FAILURE; + } + + $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; + + if (! $team instanceof Team || ! $user instanceof User) { + $this->warn('Cannot sync dev Lima servers without an existing team and user.'); + + return self::SUCCESS; + } + + $servers = $this->option('server'); + + if (! is_array($servers) || $servers === []) { + $this->warn('No dev Lima servers were provided.'); + + return self::SUCCESS; + } + + $cluster = Cluster::query()->updateOrCreate([ + 'team_id' => $team->id, + 'name' => (string) $this->option('cluster'), + ], [ + 'created_by_user_id' => $user->id, + 'description' => 'Local Lima development cluster managed by scripts/dev.sh.', + ]); + + $builderCapacity = max(0, (int) $this->option('builder-capacity')); + $builderEnabled = $builderCapacity > 0; + $capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold']; + + foreach ($servers as $server) { + $parts = explode('|', (string) $server); + + if (count($parts) !== 4) { + $this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port."); + + return self::FAILURE; + } + + [$name, $host, $sshUser, $sshPort] = $parts; + + Server::query()->updateOrCreate([ + 'team_id' => $team->id, + 'host' => $host, + 'ssh_port' => (int) $sshPort, + ], [ + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey?->id, + 'name' => $name, + 'ssh_user' => $sshUser, + 'status' => 'installed', + 'capabilities' => $capabilities, + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => $builderCapacity, + 'last_bootstrapped_at' => now(), + ]); + + $this->info("Synced {$name} ({$host}:{$sshPort})."); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/V5/HomeController.php b/app/Http/Controllers/V5/HomeController.php index 0974f820f..df53ff196 100644 --- a/app/Http/Controllers/V5/HomeController.php +++ b/app/Http/Controllers/V5/HomeController.php @@ -3,8 +3,12 @@ namespace App\Http\Controllers\V5; use App\Http\Controllers\Controller; +use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use App\Models\V5\Cluster as V5Cluster; +use App\Models\V5\Server as V5Server; +use App\Services\Coold\CoolifyCliBootstrap; use App\Services\Coold\CoolifyCliVersion; use App\Services\Flux\FluxHealth; use Illuminate\Http\JsonResponse; @@ -23,7 +27,9 @@ class HomeController extends Controller return Inertia::render('Home', [ 'status' => 'v5-ready', 'flux' => $fluxHealth->check(), - 'cooldHosts' => $this->cooldHosts(), + 'clusters' => $this->clusters($currentTeam), + 'cooldServers' => $this->cooldServers($currentTeam), + 'privateKeys' => $this->privateKeys($currentTeam), 'currentTeam' => $currentTeam instanceof Team ? [ 'id' => $currentTeam->id, 'name' => $currentTeam->name, @@ -50,27 +56,150 @@ class HomeController extends Controller return response()->json($coolifyCliVersion->check()); } - /** - * @return array, builderEnabled: bool, builderCapacity: int}> - */ - private function cooldHosts(): array + public function bootstrapCoolify(Request $request, CoolifyCliBootstrap $coolifyCliBootstrap): JsonResponse { - $baseId = (string) config('coold.dev_host_id'); - $count = max(0, (int) config('coold.dev_host_count')); - $builderCapacity = (int) config('coold.dev_builder_capacity'); - $builderEnabled = $builderCapacity > 0; + $currentTeam = $request->attributes->get('v5.currentTeam'); + $validated = $request->validate([ + 'host' => ['required', 'string', 'max:255'], + 'ssh_user' => ['required', 'string', 'max:64'], + 'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'], + 'private_key_uuid' => ['required', 'string'], + 'wg_listen_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'wg_endpoint' => ['nullable', 'string', 'max:255'], + 'enable_builder' => ['boolean'], + 'builder_capacity' => ['nullable', 'integer', 'min:0', 'max:100'], + ]); - if ($count === 0 || $baseId === '') { + if (! $currentTeam instanceof Team) { + abort(403); + } + + $privateKey = PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->where('uuid', $validated['private_key_uuid']) + ->first(); + + if (! $privateKey instanceof PrivateKey) { + return response()->json([ + 'successful' => false, + 'label' => 'Private key unavailable', + 'message' => 'The selected private key is not available for the current team.', + 'output' => null, + 'errorOutput' => null, + 'exitCode' => null, + ], 403); + } + + $result = $coolifyCliBootstrap->run($validated, $privateKey); + + if ($result['successful']) { + $this->recordBootstrappedServer($request->user(), $currentTeam, $privateKey, $validated); + } + + return response()->json($result, $result['successful'] ? 200 : 500); + } + + /** + * @param array{host: string, ssh_user: string, ssh_port: int, enable_builder?: bool, builder_capacity?: int|null} $input + */ + private function recordBootstrappedServer(User $user, Team $team, PrivateKey $privateKey, array $input): void + { + $builderEnabled = (bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true)); + $builderCapacity = $builderEnabled ? (int) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2)) : 0; + $capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold']; + + V5Server::query()->updateOrCreate([ + 'team_id' => $team->id, + 'host' => $input['host'], + 'ssh_port' => $input['ssh_port'], + ], [ + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => $input['host'], + 'ssh_user' => $input['ssh_user'], + 'status' => 'installed', + 'capabilities' => $capabilities, + 'builder_enabled' => $builderEnabled, + 'builder_capacity' => $builderCapacity, + 'last_bootstrapped_at' => now(), + ]); + } + + /** + * @return array + */ + private function privateKeys(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { return []; } - return collect(range(1, $count)) - ->map(fn (int $index) => [ - 'id' => $index === 1 ? $baseId : (string) config("coold.dev_host_id_{$index}", "{$baseId}-{$index}"), - 'wireguardIp' => (string) config("coold.dev_wireguard_ip_{$index}") ?: null, - 'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'], - 'builderEnabled' => $builderEnabled, - 'builderCapacity' => $builderCapacity, + return PrivateKey::query() + ->where('team_id', $currentTeam->id) + ->select('uuid', 'name') + ->orderBy('name') + ->get() + ->map(fn (PrivateKey $privateKey) => [ + 'uuid' => $privateKey->uuid, + 'name' => $privateKey->name, + ]) + ->all(); + } + + /** + * @return array}>}> + */ + private function clusters(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return V5Cluster::query() + ->where('team_id', $currentTeam->id) + ->with(['servers' => fn ($query) => $query->orderBy('name')]) + ->withCount('servers') + ->orderBy('name') + ->get() + ->map(fn (V5Cluster $cluster) => [ + 'id' => (string) $cluster->id, + 'name' => $cluster->name, + 'description' => $cluster->description, + 'serversCount' => $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 ?? [], + ])->all(), + ]) + ->all(); + } + + /** + * @return array, builderEnabled: bool, builderCapacity: int, lastBootstrappedAt: string|null}> + */ + private function cooldServers(mixed $currentTeam): array + { + if (! $currentTeam instanceof Team) { + return []; + } + + return V5Server::query() + ->where('team_id', $currentTeam->id) + ->orderBy('name') + ->get() + ->map(fn (V5Server $server) => [ + 'id' => (string) $server->id, + '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, + 'lastBootstrappedAt' => $server->last_bootstrapped_at?->toISOString(), ]) ->all(); } diff --git a/app/Models/V5/Cluster.php b/app/Models/V5/Cluster.php new file mode 100644 index 000000000..00fe98128 --- /dev/null +++ b/app/Models/V5/Cluster.php @@ -0,0 +1,35 @@ +belongsTo(Team::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function servers(): HasMany + { + return $this->hasMany(Server::class); + } +} diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php new file mode 100644 index 000000000..619f9074d --- /dev/null +++ b/app/Models/V5/Server.php @@ -0,0 +1,58 @@ + 'array', + 'builder_enabled' => 'boolean', + 'last_bootstrapped_at' => 'datetime', + ]; + } + + public function cluster(): BelongsTo + { + return $this->belongsTo(Cluster::class); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function privateKey(): BelongsTo + { + return $this->belongsTo(PrivateKey::class); + } +} diff --git a/app/Services/Coold/CoolifyCliBootstrap.php b/app/Services/Coold/CoolifyCliBootstrap.php new file mode 100644 index 000000000..3e9749a8b --- /dev/null +++ b/app/Services/Coold/CoolifyCliBootstrap.php @@ -0,0 +1,122 @@ +stringConfig('coold.coolify_cli_bin', '/usr/local/bin/coolify'); + $sshKeyPath = $this->writeTemporaryPrivateKey($privateKey); + + try { + $result = Process::timeout(300)->run($this->command($binary, $input, $sshKeyPath)); + } catch (Throwable $exception) { + return [ + 'successful' => false, + 'label' => 'Bootstrap failed', + 'message' => $exception->getMessage(), + 'output' => null, + 'errorOutput' => null, + 'exitCode' => null, + ]; + } finally { + @unlink($sshKeyPath); + } + + $output = trim($result->output()); + $errorOutput = trim($result->errorOutput()); + + return [ + 'successful' => $result->successful(), + 'label' => $result->successful() ? 'Bootstrap finished' : 'Bootstrap failed', + 'message' => $result->successful() + ? 'coolify init bootstrap completed successfully.' + : ($errorOutput !== '' ? $errorOutput : 'coolify init bootstrap failed.'), + 'output' => $output !== '' ? $output : null, + 'errorOutput' => $errorOutput !== '' ? $errorOutput : null, + 'exitCode' => $result->exitCode(), + ]; + } + + private function command(string $binary, array $input, string $sshKeyPath): string + { + $node = sprintf('%s:%d', $input['host'], $input['ssh_port']); + $parts = [ + $binary, + 'init', + 'bootstrap', + '--nodes', + $node, + '--ssh-key', + $sshKeyPath, + '--ssh-user', + $input['ssh_user'], + ]; + + if (! empty($input['wg_listen_port'])) { + $this->appendOptional($parts, '--wg-listen-port-overrides', sprintf('%s=%d', $node, $input['wg_listen_port'])); + } + + if (! empty($input['wg_endpoint'])) { + $this->appendOptional($parts, '--wg-endpoint-overrides', sprintf('%s=%s', $node, $input['wg_endpoint'])); + } + + $this->appendOptional($parts, '--coold-version', $this->stringConfig('coold.coold_version', 'nightly')); + $this->appendOptional($parts, '--corrosion-version', $this->stringConfig('coold.corrosion_version', 'v1.0.0')); + + if ((bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true))) { + $parts[] = '--enable-builder'; + $this->appendOptional($parts, '--builder-capacity', (string) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2))); + } + + $parts[] = '--yes'; + + return collect($parts) + ->map(fn (string $part) => escapeshellarg($part)) + ->implode(' '); + } + + private function writeTemporaryPrivateKey(PrivateKey $privateKey): string + { + $directory = storage_path('app/private/coolify-cli'); + + if (! is_dir($directory)) { + mkdir($directory, 0700, true); + } + + $path = tempnam($directory, 'ssh-key-'); + file_put_contents($path, $privateKey->private_key); + chmod($path, 0600); + + return $path; + } + + /** + * @param array $parts + */ + private function appendOptional(array &$parts, string $option, string $value): void + { + if ($value === '') { + return; + } + + $parts[] = $option; + $parts[] = $value; + } + + private function stringConfig(string $key, ?string $default = null): string + { + $value = config($key, $default); + + return is_string($value) ? trim($value) : ''; + } +} diff --git a/config/coold.php b/config/coold.php index 22a30b393..86a28a91a 100644 --- a/config/coold.php +++ b/config/coold.php @@ -2,11 +2,8 @@ return [ 'coolify_cli_bin' => env('COOLIFY_CLI_BIN', '/usr/local/bin/coolify'), - 'dev_host_count' => (int) env('COOLIFY_COOLD_VM_COUNT', 2), - 'dev_host_id' => env('COOLIFY_COOLD_DEV_HOST_ID', 'coold-dev'), - 'dev_host_id_2' => env('COOLIFY_COOLD_LIMA_INSTANCE_2', env('COOLIFY_COOLD_DEV_HOST_ID', 'coold-dev').'-2'), - 'dev_wireguard_ip_1' => env('COOLIFY_COOLD_VM_WG_IP_1', '100.64.0.1'), - 'dev_wireguard_ip_2' => env('COOLIFY_COOLD_VM_WG_IP_2', '100.64.0.2'), + 'coold_version' => env('COOLIFY_COOLD_VERSION', 'nightly'), + 'corrosion_version' => env('COOLIFY_CORROSION_VERSION', 'v1.0.0'), 'dev_builder_capacity' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2), 'dev_builder_enabled' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2) > 0, ]; diff --git a/database/migrations/2026_06_16_130649_create_v5_clusters_table.php b/database/migrations/2026_06_16_130649_create_v5_clusters_table.php new file mode 100644 index 000000000..afc820906 --- /dev/null +++ b/database/migrations/2026_06_16_130649_create_v5_clusters_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('v5_clusters'); + } +}; diff --git a/database/migrations/2026_06_16_130650_create_v5_servers_table.php b/database/migrations/2026_06_16_130650_create_v5_servers_table.php new file mode 100644 index 000000000..d7511c961 --- /dev/null +++ b/database/migrations/2026_06_16_130650_create_v5_servers_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('team_id')->constrained('teams')->cascadeOnDelete(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('private_key_id')->nullable()->constrained('private_keys')->nullOnDelete(); + $table->string('name'); + $table->string('host'); + $table->string('ssh_user'); + $table->unsignedInteger('ssh_port')->default(22); + $table->string('status')->default('installed'); + $table->json('capabilities')->nullable(); + $table->boolean('builder_enabled')->default(false); + $table->unsignedInteger('builder_capacity')->default(0); + $table->timestamp('last_bootstrapped_at')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'host', 'ssh_port']); + $table->index(['team_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('v5_servers'); + } +}; diff --git a/database/migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php b/database/migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php new file mode 100644 index 000000000..7637a42e4 --- /dev/null +++ b/database/migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php @@ -0,0 +1,35 @@ +foreignId('cluster_id') + ->nullable() + ->after('team_id') + ->constrained('v5_clusters') + ->nullOnDelete(); + + $table->index(['team_id', 'cluster_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->dropIndex(['team_id', 'cluster_id']); + $table->dropConstrainedForeignId('cluster_id'); + }); + } +}; diff --git a/database/migrations/2026_06_16_132000_make_v5_server_private_key_nullable.php b/database/migrations/2026_06_16_132000_make_v5_server_private_key_nullable.php new file mode 100644 index 000000000..cbd9dbd59 --- /dev/null +++ b/database/migrations/2026_06_16_132000_make_v5_server_private_key_nullable.php @@ -0,0 +1,28 @@ +foreignId('private_key_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v5_servers', function (Blueprint $table) { + $table->foreignId('private_key_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index 7555922ce..1b27d8b98 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -689,7 +689,7 @@ CREATE TABLE IF NOT EXISTS "servers" ( "port" INTEGER DEFAULT 22 NOT NULL, "user" TEXT DEFAULT 'root' NOT NULL, "team_id" INTEGER NOT NULL, - "private_key_id" INTEGER NOT NULL, + "private_key_id" INTEGER, "proxy" TEXT, "created_at" TEXT, "updated_at" TEXT, @@ -1321,9 +1321,39 @@ CREATE TABLE IF NOT EXISTS "users" ( "email_change_code_expires_at" TEXT ); +CREATE TABLE IF NOT EXISTS "v5_clusters" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "created_by_user_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + +CREATE TABLE IF NOT EXISTS "v5_servers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "team_id" INTEGER NOT NULL, + "cluster_id" INTEGER, + "created_by_user_id" INTEGER NOT NULL, + "private_key_id" INTEGER, + "name" TEXT NOT NULL, + "host" TEXT NOT NULL, + "ssh_user" TEXT NOT NULL, + "ssh_port" INTEGER DEFAULT '22' NOT NULL, + "status" TEXT DEFAULT 'installed' NOT NULL, + "capabilities" TEXT, + "builder_enabled" INTEGER DEFAULT false NOT NULL, + "builder_capacity" INTEGER DEFAULT '0' NOT NULL, + "last_bootstrapped_at" TEXT, + "created_at" TEXT, + "updated_at" TEXT +); + CREATE TABLE IF NOT EXISTS "v5_projects" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "team_id" INTEGER NOT NULL, + "cluster_id" INTEGER, "created_by_user_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "description" TEXT, @@ -1763,3 +1793,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_1 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 (315, '2026_06_04_050157_create_v5_projects_table', 315); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_create_v5_servers_table', 316); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_create_v5_clusters_table', 317); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_131229_add_cluster_id_to_v5_servers_table', 318); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_16_132000_make_v5_server_private_key_nullable', 319); diff --git a/resources/js/v5/Pages/Home.jsx b/resources/js/v5/Pages/Home.jsx index a48f2469d..31aa042a6 100644 --- a/resources/js/v5/Pages/Home.jsx +++ b/resources/js/v5/Pages/Home.jsx @@ -1,9 +1,33 @@ import { Head } from '@inertiajs/react'; import { useState } from 'react'; -export default function Home({ status, currentTeam, teams, flux, cooldHosts }) { +export default function Home({ status, currentTeam, teams, flux, clusters, cooldServers, privateKeys }) { + const firstPrivateKey = privateKeys[0]?.uuid || ''; const [coolify, setCoolify] = useState(null); const [checkingCoolify, setCheckingCoolify] = useState(false); + const [bootstrapResult, setBootstrapResult] = useState(null); + const [bootstrapping, setBootstrapping] = useState(false); + const [bootstrapForm, setBootstrapForm] = useState({ + host: '', + ssh_user: 'root', + ssh_port: '22', + private_key_uuid: firstPrivateKey, + wg_listen_port: '', + wg_endpoint: '', + enable_builder: true, + builder_capacity: '2', + }); + + function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + } + + function updateBootstrapForm(field, value) { + setBootstrapForm((current) => ({ + ...current, + [field]: value, + })); + } async function checkCoolifyCliVersion() { setCheckingCoolify(true); @@ -29,6 +53,49 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) { } } + async function bootstrapCoolifyMesh(event) { + event.preventDefault(); + setBootstrapping(true); + setBootstrapResult(null); + + const payload = { + host: bootstrapForm.host, + ssh_user: bootstrapForm.ssh_user, + ssh_port: Number(bootstrapForm.ssh_port), + private_key_uuid: bootstrapForm.private_key_uuid, + enable_builder: bootstrapForm.enable_builder, + builder_capacity: bootstrapForm.builder_capacity === '' ? null : Number(bootstrapForm.builder_capacity), + wg_listen_port: bootstrapForm.wg_listen_port === '' ? null : Number(bootstrapForm.wg_listen_port), + wg_endpoint: bootstrapForm.wg_endpoint || null, + }; + + try { + const response = await fetch('/v5/coolify/bootstrap', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify(payload), + }); + const result = await response.json(); + + setBootstrapResult(result); + } catch (error) { + setBootstrapResult({ + successful: false, + label: 'Bootstrap failed', + message: 'Could not start the coolify bootstrap command.', + output: null, + errorOutput: null, + exitCode: null, + }); + } finally { + setBootstrapping(false); + } + } + return ( <> @@ -55,22 +122,52 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) { {flux.socket ?

Socket: {flux.socket}

: null} -
-

coold host

+
+

Clusters

-
    - {cooldHosts.map((host) => ( -
  • - {host.id} - {host.wireguardIp ? ` (${host.wireguardIp})` : ''}: - {' '} - {host.capabilities.join(', ')}; builder{' '} - {host.builderEnabled - ? `enabled, capacity ${host.builderCapacity}` - : 'disabled'} -
  • - ))} -
+ {clusters.length === 0 ? ( +

No clusters have been added yet.

+ ) : ( +
    + {clusters.map((cluster) => ( +
  • + {cluster.name} — {cluster.serversCount}{' '} + {cluster.serversCount === 1 ? 'server' : 'servers'} + {cluster.description ?

    {cluster.description}

    : null} + {cluster.servers.length > 0 ? ( +
      + {cluster.servers.map((server) => ( +
    • + {server.name} ({server.status}) — {server.host};{' '} + {server.capabilities.join(', ')} +
    • + ))} +
    + ) : null} +
  • + ))} +
+ )} +
+ +
+

coold servers

+ + {cooldServers.length === 0 ? ( +

No coold serverss have been added yet.

+ ) : ( +
    + {cooldServers.map((host) => ( +
  • + {host.host} ({host.status}) — SSH {host.sshUser}@{host.host}:{host.sshPort};{' '} + {host.capabilities.join(', ')}; builder{' '} + {host.builderEnabled + ? `enabled, capacity ${host.builderCapacity}` + : 'disabled'} +
  • + ))} +
+ )}
@@ -90,6 +187,126 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) { {coolify.binary ?

Binary: {coolify.binary}

: null} ) : null} + +
+

Bootstrap server

+ + + + + + + + + +
+ Advanced mesh options + + + + + + + + +
+ + + + {privateKeys.length === 0 ? ( +

Add a private key before bootstrapping a server.

+ ) : null} +
+ + {bootstrapResult ? ( +
+

+ {bootstrapResult.label} +

+

{bootstrapResult.message}

+ {bootstrapResult.exitCode !== null ? ( +

Exit code: {bootstrapResult.exitCode}

+ ) : null} + {bootstrapResult.output ?
{bootstrapResult.output}
: null} + {bootstrapResult.errorOutput ?
{bootstrapResult.errorOutput}
: null} +
+ ) : null}

Current team

diff --git a/routes/v5.php b/routes/v5.php index 766047255..8dbc8460e 100644 --- a/routes/v5.php +++ b/routes/v5.php @@ -6,4 +6,5 @@ use Illuminate\Support\Facades\Route; Route::middleware('v5.authenticated')->group(function () { Route::get('/', HomeController::class)->name('home'); Route::get('/coolify/version', [HomeController::class, 'coolifyCliVersion'])->name('coolify.version'); + Route::post('/coolify/bootstrap', [HomeController::class, 'bootstrapCoolify'])->name('coolify.bootstrap'); }); diff --git a/scripts/dev.sh b/scripts/dev.sh index 45f8a8f03..1d9362828 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -279,6 +279,24 @@ coolify_bootstrap() { --yes } +coolify_bootstrap_with_retry() { + local attempt + local attempts=5 + + for attempt in $(seq 1 "$attempts"); do + if coolify_bootstrap; then + return + fi + + if [ "$attempt" = "$attempts" ]; then + return 1 + fi + + echo "WARN: coolify bootstrap failed on attempt ${attempt}/${attempts}; retrying because fresh Lima hosts can finish setup after partial bootstrap phases..." >&2 + sleep 3 + done +} + coolify_dev() { local command="${1:-help}" if [ $# -gt 0 ]; then @@ -398,6 +416,32 @@ follow_logs() { spin logs -f } +sync_v5_dev_lima_servers() { + local count + local builder_capacity + local args=() + local instance + local node + + count="$(coold_vm_count)" + builder_capacity="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)" + + for index in $(seq 1 "$count"); do + instance="$(coold_vm_instance "$index")" + node="$(lima_ssh_target "$index")" + args+=(--server "${instance}|${node}|$(coolify_ssh_user)|22") + done + + echo "==> Running pending migrations before syncing v5 dev Lima state..." + spin exec -T coolify php artisan migrate --force + + echo "==> Syncing dev Lima VM(s) into v5 clusters/servers..." + spin exec -T coolify php artisan v5:sync-dev-lima-servers \ + --cluster="Development-Lima" \ + --builder-capacity="$builder_capacity" \ + "${args[@]}" +} + configure_flux_dev_for_vm() { local index="$1" local host_id @@ -429,10 +473,25 @@ up() { local coold_vm_enabled local follow_dev_logs local count + local naked=false + local spin_args=() coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)" follow_dev_logs="$(read_coolify_env COOLIFY_DEV_FOLLOW_LOGS true)" count="$(coold_vm_count)" + while [ $# -gt 0 ]; do + case "$1" in + --naked) + naked=true + shift + ;; + *) + spin_args+=("$1") + shift + ;; + esac + done + if [ "$coold_vm_enabled" != "false" ]; then echo "==> Starting ${count} Coolify coold VM(s) before Spin..." for index in $(seq 1 "$count"); do @@ -443,15 +502,26 @@ up() { fi echo "==> Starting Coolify Docker stack with Spin..." - spin up -d "$@" + if [ "${#spin_args[@]}" -gt 0 ]; then + spin up -d "${spin_args[@]}" + else + spin up -d + fi + + if [ "$naked" = "true" ]; then + echo "==> --naked enabled. Skipping coolify bootstrap and Flux VM wiring. Use /v5 to bootstrap hosts from the UI." + return + fi if [ "$coold_vm_enabled" != "false" ]; then echo "==> Bootstrapping coold VM mesh with coolify..." - coolify_bootstrap + coolify_bootstrap_with_retry for index in $(seq 1 "$count"); do configure_flux_dev_for_vm "$index" done + + sync_v5_dev_lima_servers fi if [ "$follow_dev_logs" = "false" ]; then @@ -465,9 +535,24 @@ up() { down() { local coold_vm_enabled local stop_coold_vm + local cleanup=false + local spin_args=() coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)" stop_coold_vm="$(read_coolify_env COOLIFY_COOLD_VM_STOP_ON_DOWN false)" + while [ $# -gt 0 ]; do + case "$1" in + --cleanup) + cleanup=true + shift + ;; + *) + spin_args+=("$1") + shift + ;; + esac + done + if [ "$coold_vm_enabled" != "false" ]; then for index in $(seq 1 "$(coold_vm_count)"); do echo "==> Stopping coold VM agent service on $(coold_vm_instance "$index")..." @@ -476,7 +561,16 @@ down() { fi echo "==> Stopping Coolify Docker stack with Spin..." - spin down "$@" + if [ "${#spin_args[@]}" -gt 0 ]; then + spin down "${spin_args[@]}" + else + spin down + fi + + if [ "$cleanup" = "true" ]; then + clean_vms + return + fi if [ "$stop_coold_vm" = "true" ]; then echo "==> Stopping Coolify coold VM..." @@ -765,11 +859,11 @@ firewall_help() { Usage: scripts/dev.sh firewall Commands: - allow [proto] [port] Allow traffic on every coold VM (proto/port optional) + allow [proto] [port] Allow traffic through the coolify CLI (proto/port optional) revoke [id|src] [dst] [proto] [port] - Remove an allow rule from every coold VM - list List allow rules on every coold VM - reconcile Re-apply firewall snapshot on every coold VM + Remove an allow rule through the coolify CLI + list List allow rules through the coolify CLI + containers List registered containers through the coolify CLI Examples: scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80 @@ -780,29 +874,21 @@ Examples: USAGE } -firewall_api_for_each_vm() { - local label="$1" - local method="$2" - local path="$3" - local body="${4:-}" - local count - count="$(coold_vm_count)" +coolify_firewall() { + local command="$1" + shift + local nodes + local ssh_config - for index in $(seq 1 "$count"); do - instance="$(coold_vm_instance "$index")" - api_ip="$(coold_vm_wg_ip "$index")" - echo "--- ${instance}: ${label} ---" - COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell <&2 @@ -822,31 +908,15 @@ firewall_allow() { exit 1 fi - body="$(printf '{"namespace":"default","src":"%s","dst":"%s"' "$src" "$dst")" + args+=(--from "$src" --to "$dst") if [ -n "$proto" ]; then - body="${body}$(printf ',"proto":"%s"' "$proto")" + args+=(--proto "$proto") fi if [ -n "$port" ]; then - body="${body}$(printf ',"port":%s' "$port")" - fi - body="${body}}" - - firewall_api_for_each_vm "allow ${src} -> ${dst}" POST /api/v1/firewall/allow "$body" -} - -firewall_rule_id() { - local src="$1" - local dst="$2" - local proto="${3:-}" - local port="${4:-0}" - - if [ -z "$proto" ]; then - port=0 + args+=(--port "$port") fi - printf 'default|%s|%s|%s|%s' "$src" "$dst" "$proto" "$port" \ - | shasum -a 256 \ - | awk '{print substr($1, 1, 12)}' + coolify_firewall allow "${args[@]}" } firewall_revoke() { @@ -854,7 +924,7 @@ firewall_revoke() { local dst="${2:-}" local proto="${3:-}" local port="${4:-}" - local id + local args=() if [ -z "$id_or_src" ]; then echo "Current firewall allow rule IDs:" @@ -865,20 +935,26 @@ firewall_revoke() { fi if [ -z "$dst" ]; then - id="$id_or_src" + args+=(--id "$id_or_src") else - id="$(firewall_rule_id "$id_or_src" "$dst" "$proto" "$port")" + args+=(--from "$id_or_src" --to "$dst") + if [ -n "$proto" ]; then + args+=(--proto "$proto") + fi + if [ -n "$port" ]; then + args+=(--port "$port") + fi fi - firewall_api_for_each_vm "revoke ${id}" DELETE "/api/v1/firewall/allow/${id}" + coolify_firewall revoke "${args[@]}" } firewall_list() { - firewall_api_for_each_vm list GET '/api/v1/firewall/allow?namespace=default' + coolify_firewall list "$@" } -firewall_reconcile() { - firewall_api_for_each_vm reconcile POST /api/v1/firewall/reconcile +firewall_containers() { + coolify_firewall containers "$@" } firewall() { @@ -895,10 +971,10 @@ firewall() { firewall_revoke "$@" ;; list) - firewall_list + firewall_list "$@" ;; - reconcile) - firewall_reconcile + containers) + firewall_containers "$@" ;; -h|--help|help) firewall_help @@ -917,10 +993,14 @@ Usage: scripts/dev.sh [spin args] Commands: up Start the coold VM, Spin stack, and dev coold agent + up --naked + Start the coold VM(s) and Spin stack only; skip host bootstrap so /v5 can bootstrap down Stop the dev coold agent and Spin stack + down --cleanup + Stop the dev stack, then delete the coold Lima VM(s) and VM-local state shell [n] Open a shell inside coold VM n (default: 1) list Show Lima instances - clean-vms Delete the coold Lima VMs and all VM-local runtime state + clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup) corrosion Inspect Corrosion state, config, logs, and registered containers firewall Manage dev coold firewall allow rules example-nginx Start/check example nginx containers with coold DNS @@ -947,7 +1027,7 @@ case "$cmd" in limactl list ;; clean-vms|clean-vm|reset-vms) - clean_vms + down --cleanup ;; corrosion) corrosion "$@" diff --git a/tests/Feature/DevScriptFirewallDelegationTest.php b/tests/Feature/DevScriptFirewallDelegationTest.php new file mode 100644 index 000000000..f243adc55 --- /dev/null +++ b/tests/Feature/DevScriptFirewallDelegationTest.php @@ -0,0 +1,80 @@ +toContain('coolify_firewall()') + ->and($script)->toContain('"$(coolify_cli_bin)" firewall "$command"') + ->and($script)->toContain('--nodes "$nodes"') + ->and($script)->toContain('--ssh-config "$ssh_config"') + ->and($script)->not->toContain('/api/v1/firewall/allow') + ->and($script)->not->toContain('firewall_api_for_each_vm'); +}); + +it('installs the coolify CLI in both application container images', function (string $dockerfile) { + $contents = file_get_contents(base_path($dockerfile)); + + expect($contents)->toContain('ARG COOLIFY_CLI_VERSION=nightly') + ->and($contents)->toContain('coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz') + ->and($contents)->toContain('install -m 0755 /tmp/coolify /usr/local/bin/coolify'); +})->with([ + 'development image' => 'docker/development/Dockerfile', + 'production image' => 'docker/production/Dockerfile', +]); + +it('does not require predefined UI node environment variables in the development app container', function () { + $compose = file_get_contents(base_path('docker-compose.dev.yml')); + $config = file_get_contents(base_path('config/coold.php')); + + expect($compose)->not->toContain('COOLIFY_CLI_NODES:') + ->and($compose)->not->toContain('COOLIFY_CLI_SSH_CONFIG:') + ->and($compose)->not->toContain('COOLIFY_CLI_WG_LISTEN_PORT_OVERRIDES:') + ->and($compose)->not->toContain('COOLIFY_CLI_WG_ENDPOINT_OVERRIDES:') + ->and($compose)->not->toContain('COOLIFY_CLI_TIMEOUT:') + ->and($config)->not->toContain('cli_nodes') + ->and($config)->not->toContain('COOLIFY_CLI_NODES'); +}); + +it('supports a naked up mode that starts VMs and Spin but skips server bootstrap wiring', function () { + $script = file_get_contents(base_path('scripts/dev.sh')); + + expect($script)->toContain('local naked=false') + ->and($script)->toContain('--naked') + ->and($script)->toContain('if [ "${#spin_args[@]}" -gt 0 ]; then') + ->and($script)->toContain('spin up -d "${spin_args[@]}"') + ->and($script)->toContain('spin up -d') + ->and($script)->toContain('if [ "$naked" = "true" ]; then') + ->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring') + ->and($script)->toContain('coolify_bootstrap_with_retry') + ->and($script)->toContain('configure_flux_dev_for_vm "$index"') + ->and($script)->toContain('sync_v5_dev_lima_servers'); +}); + +it('retries dev coolify bootstrap because fresh Lima setup can complete across partial phases', function () { + $script = file_get_contents(base_path('scripts/dev.sh')); + + expect($script)->toContain('coolify_bootstrap_with_retry()') + ->and($script)->toContain('local attempts=5') + ->and($script)->toContain('if coolify_bootstrap; then') + ->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases'); +}); + +it('syncs bootstrapped Lima VMs into v5 development server state', function () { + $script = file_get_contents(base_path('scripts/dev.sh')); + + expect($script)->toContain('sync_v5_dev_lima_servers()') + ->and($script)->toContain('v5:sync-dev-lima-servers') + ->and($script)->toContain('--cluster="Development-Lima"') + ->and($script)->toContain('--server "${instance}|${node}|$(coolify_ssh_user)|22"'); +}); + +it('supports down cleanup as the preferred VM cleanup command', function () { + $script = file_get_contents(base_path('scripts/dev.sh')); + + expect($script)->toContain('local cleanup=false') + ->and($script)->toContain('--cleanup') + ->and($script)->toContain('if [ "$cleanup" = "true" ]; then') + ->and($script)->toContain('clean_vms') + ->and($script)->toContain('down --cleanup') + ->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)'); +}); diff --git a/tests/Feature/V5/HomeTest.php b/tests/Feature/V5/HomeTest.php index 26914cf3a..50bdb3b5c 100644 --- a/tests/Feature/V5/HomeTest.php +++ b/tests/Feature/V5/HomeTest.php @@ -5,9 +5,13 @@ use App\Http\Middleware\CheckForcePasswordReset; use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\V5\EnsureCurrentTeam; use App\Http\Middleware\V5\HandleInertiaRequests; +use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use App\Models\V5\Cluster; +use App\Models\V5\Server as V5Server; use App\Services\Flux\FluxHealth; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Route; @@ -18,7 +22,10 @@ beforeEach(function () { Config::set('app.maintenance.store', 'array'); Config::set('cache.default', 'array'); + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); Schema::dropIfExists('v5_projects'); + Schema::dropIfExists('private_keys'); Schema::dropIfExists('team_user'); Schema::dropIfExists('teams'); Schema::dropIfExists('users'); @@ -63,13 +70,87 @@ it('creates v5 project tables in the shared database', function () { ]))->toBeTrue(); }); +it('creates v5 cluster tables and lets each server belong to one cluster', function () { + createSharedUserAndTeamTables(); + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php'); + $clusterMigration->up(); + + expect(Schema::hasTable('v5_clusters'))->toBeTrue() + ->and(Schema::hasColumns('v5_clusters', [ + 'id', + 'team_id', + 'created_by_user_id', + 'name', + 'description', + 'created_at', + 'updated_at', + ]))->toBeTrue(); + + Schema::dropIfExists('v5_servers'); + + $serverMigration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php'); + $serverMigration->up(); + + $serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php'); + $serverClusterMigration->up(); + + expect(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue(); +}); + +it('creates v5 server tables in the shared database', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php'); + $clusterMigration->up(); + + $migration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php'); + $migration->up(); + + $serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php'); + $serverClusterMigration->up(); + + expect(Schema::hasTable('v5_servers'))->toBeTrue() + ->and(Schema::hasColumns('v5_servers', [ + 'id', + 'team_id', + 'cluster_id', + 'created_by_user_id', + 'private_key_id', + 'name', + 'host', + 'ssh_user', + 'ssh_port', + 'status', + 'capabilities', + 'builder_enabled', + 'builder_capacity', + 'last_bootstrapped_at', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + it('includes v5 project tables in the dev testing schema', function () { $schema = file_get_contents(database_path('schema/testing-schema.sql')); expect($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"') ->and($schema)->toContain('"team_id" INTEGER NOT NULL') ->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL') - ->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table'); + ->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table') + ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"') + ->and($schema)->toContain('"cluster_id" INTEGER') + ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"') + ->and($schema)->toContain('"private_key_id" INTEGER') + ->and($schema)->toContain('2026_06_16_130650_create_v5_servers_table') + ->and($schema)->toContain('2026_06_16_130649_create_v5_clusters_table') + ->and($schema)->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table') + ->and($schema)->not->toContain('v5_hosts'); }); it('redirects guests to the shared login', function () { @@ -106,18 +187,57 @@ it('serves the v5 inertia shell', function () { ->assertSee('v5-ready', false) ->assertSee('Running') ->assertSee('Flux is running.') - ->assertSee('coold-dev') - ->assertSee('coold-dev-2') - ->assertSee('100.64.0.1') - ->assertSee('100.64.0.2') - ->assertSee('builder') - ->assertSee('builderCapacity') + ->assertSee('"clusters":[]', false) + ->assertSee('"cooldServers":[]', false) + ->assertDontSee('coold-dev') + ->assertDontSee('100.64.0.1') ->assertSee('V5 Shared Team') ->assertSee('Shared team details') ->assertSee('owner') ->assertSee($user->email); }); +it('shows v5 clusters with their servers on the inertia shell', function () { + $this->withoutVite(); + fakeFluxHealth(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Lima Key'); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Development-Lima', + 'description' => 'Local Lima development cluster managed by scripts/dev.sh.', + ]); + V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'coold-dev', + 'host' => 'lima-coold-dev', + 'ssh_user' => 'developer', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold', 'builder'], + 'builder_enabled' => true, + 'builder_capacity' => 2, + 'last_bootstrapped_at' => now(), + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->get('/v5') + ->assertSuccessful() + ->assertSee('"clusters":[', false) + ->assertSee('"name":"Development-Lima"', false) + ->assertSee('"serversCount":1', false) + ->assertSee('"name":"coold-dev"', false) + ->assertSee('"host":"lima-coold-dev"', false); +}); + it('selects a shared team when the session has no current team', function () { $this->withoutVite(); fakeFluxHealth(); @@ -218,6 +338,127 @@ it('shows when coolify version check fails', function () { ]); }); +it('rejects coolify bootstrap when the selected private key is not owned by the current team', function () { + createSharedUserAndTeamTables(); + [$user, $team] = createV5UserWithTeam(); + $otherTeam = Team::withoutEvents(fn () => Team::query()->create([ + 'name' => 'Other Team', + 'description' => null, + 'personal_team' => false, + 'show_boarding' => false, + ])); + $privateKey = createV5PrivateKey($otherTeam, 'Other Key'); + + Process::fake(); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson('/v5/coolify/bootstrap', [ + 'host' => '192.0.2.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'private_key_uuid' => $privateKey->uuid, + ]) + ->assertForbidden() + ->assertJson([ + 'successful' => false, + 'label' => 'Private key unavailable', + ]); + + Process::assertDidntRun(fn () => true); +}); + +it('runs coolify bootstrap from dynamic UI input and a selected team private key', function () { + createSharedUserAndTeamTables(); + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Bootstrap Key'); + + Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify'); + Config::set('coold.dev_builder_capacity', 2); + + Process::fake([ + '*' => Process::result(output: 'Bootstrapping mesh...', exitCode: 0), + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson('/v5/coolify/bootstrap', [ + 'host' => '192.0.2.10', + 'ssh_user' => 'ubuntu', + 'ssh_port' => 2222, + 'private_key_uuid' => $privateKey->uuid, + 'wg_listen_port' => 51821, + 'wg_endpoint' => 'example.test:51821', + 'enable_builder' => true, + 'builder_capacity' => 3, + ]) + ->assertSuccessful() + ->assertJson([ + 'successful' => true, + 'label' => 'Bootstrap finished', + 'message' => 'coolify init bootstrap completed successfully.', + 'output' => 'Bootstrapping mesh...', + 'exitCode' => 0, + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->get('/v5') + ->assertSuccessful() + ->assertSee('"host":"192.0.2.10"', false) + ->assertSee('"status":"installed"', false) + ->assertSee('"capabilities":["coold","builder"]', false); + + $sshKeyPath = null; + + Process::assertRan(function ($process) use (&$sshKeyPath) { + preg_match("/'--ssh-key' '([^']+)'/", $process->command, $matches); + $sshKeyPath = $matches[1] ?? null; + + return $process->timeout === 300 + && str_contains($process->command, "'/usr/local/bin/coolify' 'init' 'bootstrap'") + && str_contains($process->command, "'--nodes' '192.0.2.10:2222'") + && str_contains($process->command, "'--ssh-user' 'ubuntu'") + && str_contains($process->command, "'--wg-listen-port-overrides' '192.0.2.10:2222=51821'") + && str_contains($process->command, "'--wg-endpoint-overrides' '192.0.2.10:2222=example.test:51821'") + && str_contains($process->command, "'--coold-version' 'nightly'") + && str_contains($process->command, "'--corrosion-version' 'v1.0.0'") + && str_contains($process->command, "'--enable-builder'") + && str_contains($process->command, "'--builder-capacity' '3'") + && str_contains($process->command, "'--yes'") + && ! str_contains($process->command, 'COOLIFY_CLI_NODES'); + }); + + expect($sshKeyPath)->not->toBeNull() + ->and(file_exists($sshKeyPath))->toBeFalse(); +}); + +it('syncs dev Lima VMs into v5 clusters and servers', function () { + createSharedUserAndTeamTables(); + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Dev Lima Key'); + + $exitCode = Artisan::call('v5:sync-dev-lima-servers', [ + '--team-id' => $team->id, + '--user-id' => $user->id, + '--private-key-id' => $privateKey->id, + '--cluster' => 'Development-Lima', + '--builder-capacity' => 2, + '--server' => [ + 'coold-dev|lima-coold-dev|developer|22', + 'coold-dev-2|lima-coold-dev-2|developer|22', + ], + ]); + + expect($exitCode)->toBe(0) + ->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1) + ->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'lima-coold-dev')->exists())->toBeTrue() + ->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'lima-coold-dev-2')->exists())->toBeTrue(); +}); + function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void { app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) { @@ -253,6 +494,45 @@ function createSharedUserAndTeamTables(): void $table->timestamps(); }); + Schema::create('private_keys', function ($table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('name'); + $table->string('description')->nullable(); + $table->longText('private_key'); + $table->string('fingerprint')->nullable(); + $table->boolean('is_git_related')->default(false); + $table->foreignId('team_id'); + $table->timestamps(); + }); + + Schema::create('v5_clusters', function ($table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('created_by_user_id'); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('v5_servers', function ($table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('cluster_id')->nullable(); + $table->foreignId('created_by_user_id'); + $table->foreignId('private_key_id')->nullable(); + $table->string('name'); + $table->string('host'); + $table->string('ssh_user'); + $table->unsignedInteger('ssh_port'); + $table->string('status')->default('installed'); + $table->json('capabilities')->nullable(); + $table->boolean('builder_enabled')->default(false); + $table->unsignedInteger('builder_capacity')->default(0); + $table->timestamp('last_bootstrapped_at')->nullable(); + $table->timestamps(); + }); + Schema::create('team_user', function ($table) { $table->id(); $table->foreignId('team_id'); @@ -264,6 +544,19 @@ function createSharedUserAndTeamTables(): void }); } +function createV5PrivateKey(Team $team, string $name): PrivateKey +{ + return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([ + 'uuid' => str($name)->slug().'-uuid', + 'name' => $name, + 'description' => null, + 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n", + 'fingerprint' => str($name)->slug()->toString(), + 'is_git_related' => false, + 'team_id' => $team->id, + ])); +} + /** * @return array{0: User, 1: Team} */