feat(v5): add server clusters and CLI bootstrap

Create v5 cluster/server persistence, expose them on the home page,
and add a bootstrap endpoint backed by the coolify CLI. Add dev Lima
server sync support and update the dev script firewall flow.
This commit is contained in:
Andras Bacsai committed 2026-06-16 15:45:16 +02:00
1 parent 586588196d
commit 1e038fecae
16 files changed
+1389 -108

No files matched your search

@@ -0,0 +1,80 @@
<?php
it('delegates dev firewall commands to the coolify CLI instead of calling coold APIs directly', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->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)');
});
+300 -7
View File
@@ -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}
*/