Files
coolify/tests/Feature/V5/DashboardTest.php
T
Andras Bacsai 3ae87334bd feat(v5): add WireGuard and coold/corrosion config to clusters/servers
Add WireGuard networking fields (interface, management pool, listen port),
container network pool, coold/corrosion versioning, and builder CPU quota
to clusters and servers via new migrations and model fillables.

Expose full cluster and server CRUD on the Clusters page with private key
selection, pending state tracking, and new form primitives (Field, Input,
Textarea). Add server status check fields and a lima test VM config for
development.
2026-06-19 07:23:45 +02:00

2183 lines
86 KiB
PHP

<?php
use App\Http\Controllers\V5\DashboardController;
use App\Http\Kernel;
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\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
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 Database\Seeders\V5DevLimaSeeder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
beforeEach(function () {
Config::set('app.maintenance.store', 'array');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
});
it('registers the v5 dashboard route', function () {
expect(Route::has('v5.dashboard'))->toBeTrue()
->and(Route::has('v5.home'))->toBeFalse()
->and(Route::has('v5.selection.update'))->toBeTrue()
->and(Route::has('v5.clusters.index'))->toBeTrue()
->and(Route::has('v5.clusters.store'))->toBeTrue()
->and(Route::has('v5.clusters.destroy'))->toBeTrue()
->and(Route::has('v5.clusters.servers.store'))->toBeTrue()
->and(Route::has('v5.clusters.servers.update'))->toBeTrue()
->and(Route::has('v5.clusters.servers.check'))->toBeTrue()
->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue()
->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue()
->and(Route::has('v5.coolify.version'))->toBeFalse()
->and(Route::has('v5.coolify.bootstrap'))->toBeFalse();
});
it('uses separated v5 middleware groups', function () {
$kernel = app(Kernel::class);
$reflection = new ReflectionClass($kernel);
$property = $reflection->getProperty('middlewareGroups');
$property->setAccessible(true);
$groups = $property->getValue($kernel);
expect($groups)->toHaveKey('v5.web')
->and($groups)->toHaveKey('v5.authenticated')
->and($groups['v5.web'])->toContain(HandleInertiaRequests::class)
->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class)
->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class)
->and($groups['v5.authenticated'])->toContain('auth')
->and($groups['v5.authenticated'])->toContain('verified')
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
});
it('reuses existing projects instead of creating v5 projects', function () {
expect(file_exists(database_path('migrations/2026_06_04_050157_v5_create_projects_table.php')))->toBeFalse()
->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse();
});
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_v5_create_clusters_table.php');
$clusterMigration->up();
$serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$serverMigration->up();
$configurationMigration = include database_path('migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php');
$configurationMigration->up();
$statusCheckMigration = include database_path('migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php');
$statusCheckMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
->and(Schema::hasColumns('v5_clusters', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
'created_at',
'updated_at',
]))->toBeTrue()
->and(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_v5_create_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php');
$migration->up();
$configurationMigration = include database_path('migrations/2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers.php');
$configurationMigration->up();
$statusCheckMigration = include database_path('migrations/2026_06_17_172845_add_status_check_fields_to_v5_servers_table.php');
$statusCheckMigration->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',
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'last_bootstrapped_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('adds builder cpu quota to existing v5 server tables safely', function () {
Schema::dropIfExists('v5_servers');
Schema::create('v5_servers', function ($table) {
$table->id();
$table->unsignedInteger('builder_capacity')->default(0);
});
$migration = include database_path('migrations/2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table.php');
$migration->up();
$migration->up();
expect(Schema::hasColumn('v5_servers', 'builder_cpu_quota'))->toBeTrue();
$migration->down();
$migration->down();
expect(Schema::hasColumn('v5_servers', 'builder_cpu_quota'))->toBeFalse();
});
it('includes v5 tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->not->toContain('2026_06_04_050157_v5_create_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('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL')
->and($schema)->toContain('"wireguard_management_pool" TEXT DEFAULT \'100.64.0.0/16\' NOT NULL')
->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL')
->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL')
->and($schema)->toContain('"wireguard_management_ip" TEXT')
->and($schema)->toContain('"container_subnets" JSON')
->and($schema)->toContain('"last_status_output" TEXT')
->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table')
->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table')
->and($schema)->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
->and($schema)->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table')
->and($schema)->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_16_132000_make_v5_server_private_key_nullable')
->and($schema)->not->toContain('v5_hosts');
});
it('redirects guests to the shared login', function () {
$this->get('/v5')
->assertRedirect('/login');
});
it('serves the v5 inertia shell', function () {
app()->detectEnvironment(fn () => 'local');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Shared Team',
'description' => 'Shared team details',
'personal_team' => true,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('<html lang="en" class="dark">', false)
->assertSee('coolify-logo-dev-transparent.png', false)
->assertDontSee('coolify-logo.svg', false)
->assertSee('v5-app', false)
->assertSee('Dashboard', false)
->assertDontSee('Home', false)
->assertDontSee('v5-ready', false)
->assertDontSee('This page is served from Laravel through Inertia and React')
->assertDontSee('Bootstrap server')
->assertDontSee('privateKeys', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertDontSee('"clusters":', false)
->assertDontSee('cooldServers', false)
->assertDontSee('coold-dev')
->assertDontSee('Create cluster')
->assertDontSee('Cluster details')
->assertDontSee('100.64.0.1')
->assertDontSee('Current team')
->assertDontSee('Your teams')
->assertDontSee('currentTeam', false)
->assertDontSee('teams', false)
->assertDontSee('V5 Shared Team')
->assertDontSee('Shared team details');
});
it('serves the production v5 favicon outside local environments', function () {
app()->detectEnvironment(fn () => 'production');
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('coolify-logo.svg', false)
->assertDontSee('coolify-logo-dev-transparent.png', false);
});
it('shows v5 clusters with their servers on the cluster page', 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/clusters')
->assertSuccessful()
->assertSee('Clusters', false)
->assertSee('"clusters":[', false)
->assertSee('"privateKeys":[', false)
->assertSee('"name":"Lima Key"', false)
->assertSee('"name":"Development-Lima"', false)
->assertSee('"serversCount":1', false)
->assertSee('"name":"coold-dev"', false)
->assertSee('"host":"lima-coold-dev"', false)
->assertDontSee('"sshUser"', false)
->assertDontSee('"sshPort"', false)
->assertSee('"builderEnabled":true', false)
->assertSee('"builderCapacity":2', false)
->assertSee('"wireguardInterface":"wg0"', false)
->assertSee('"wireguardManagementPool":"100.64.0.0\\/16"', false)
->assertSee('"containerNetworkPool":"10.210.0.0\\/16"', false)
->assertSee('"namespaces":["default"]', false)
->assertSee('"defaultDenyContainers":true', false)
->assertSee('"cooldVersion":"nightly"', false)
->assertSee('"corrosionVersion":"v1.0.0"', false)
->assertSee('"builderCpuQuota":"200%"', false)
->assertSee('"builderMemoryMax":"2G"', false)
->assertSee('"builderTimeoutSecs":1800', false)
->assertSee('"lastCliStatus":null', false)
->assertSee('"privateKeyName":"Lima Key"', false)
->assertSee('"nodeAddress":null', false)
->assertSee('"wireguardManagementIp":null', false)
->assertSee('"containerSubnets":[]', false)
->assertSee('"lastBootstrappedAt":"', false);
});
it('creates a v5 cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Primary production cluster.',
])
->assertCreated()
->assertJsonPath('cluster.name', 'Production Mesh')
->assertJsonPath('cluster.description', 'Primary production cluster.')
->assertJsonPath('cluster.wireguardInterface', 'wg0')
->assertJsonPath('cluster.wireguardManagementPool', '100.64.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51820)
->assertJsonPath('cluster.containerNetworkPool', '10.210.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 24)
->assertJsonPath('cluster.namespaces', ['default'])
->assertJsonPath('cluster.defaultDenyContainers', true)
->assertJsonPath('cluster.cooldVersion', 'nightly')
->assertJsonPath('cluster.corrosionVersion', 'v1.0.0')
->assertJsonPath('cluster.corrosionGossipPort', 8787)
->assertJsonPath('cluster.corrosionApiPort', 8080)
->assertJsonPath('cluster.builderEnabled', true)
->assertJsonPath('cluster.builderCapacity', 2)
->assertJsonPath('cluster.builderCpuQuota', '200%')
->assertJsonPath('cluster.builderMemoryMax', '2G')
->assertJsonPath('cluster.builderTimeoutSecs', 1800)
->assertJsonPath('cluster.lastCliAction', null)
->assertJsonPath('cluster.lastCliStatus', null)
->assertJsonPath('cluster.lastCliSummary', null)
->assertJsonPath('cluster.lastCliRanAt', null)
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(Cluster::query()
->where('team_id', $team->id)
->where('created_by_user_id', $user->id)
->where('name', 'Production Mesh')
->where('description', 'Primary production cluster.')
->where('wireguard_interface', 'wg0')
->where('wireguard_management_pool', '100.64.0.0/16')
->where('container_network_pool', '10.210.0.0/16')
->exists())->toBeTrue();
});
it('creates a v5 cluster with advanced cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Custom Mesh',
'description' => null,
'wireguard_interface' => 'wg-prod',
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_listen_port' => 51830,
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'namespaces' => ['default', 'preview'],
'default_deny_containers' => false,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
])
->assertCreated()
->assertJsonPath('cluster.wireguardInterface', 'wg-prod')
->assertJsonPath('cluster.wireguardManagementPool', '100.65.0.0/16')
->assertJsonPath('cluster.wireguardListenPort', 51830)
->assertJsonPath('cluster.containerNetworkPool', '10.211.0.0/16')
->assertJsonPath('cluster.containerNetworkPrefix', 25)
->assertJsonPath('cluster.namespaces', ['default', 'preview'])
->assertJsonPath('cluster.defaultDenyContainers', false)
->assertJsonPath('cluster.cooldVersion', 'v0.2.0')
->assertJsonPath('cluster.corrosionVersion', 'v1.1.0')
->assertJsonPath('cluster.builderCapacity', 4)
->assertJsonPath('cluster.builderCpuQuota', '400%')
->assertJsonPath('cluster.builderMemoryMax', '4G')
->assertJsonPath('cluster.builderTimeoutSecs', 2400);
});
it('validates advanced v5 cluster cli configuration', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Broken Mesh',
'wireguard_management_pool' => 'not-a-cidr',
'container_network_pool' => '10.211.0.0',
'wireguard_listen_port' => 70000,
'namespaces' => ['Default'],
])
->assertUnprocessable()
->assertJsonValidationErrors([
'wireguard_management_pool',
'container_network_pool',
'wireguard_listen_port',
'namespaces.0',
]);
});
it('adds a v5 server to a cluster for the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'builder_enabled' => true,
'builder_capacity' => 3,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_id' => $privateKey->id,
'node_address' => '203.0.113.10',
'builder_enabled' => true,
'builder_capacity' => 3,
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'prod-01.example.com:51821',
])
->assertCreated()
->assertJsonPath('cluster.serversCount', 1)
->assertJsonPath('cluster.servers.0.name', 'prod-01')
->assertJsonPath('cluster.servers.0.host', '203.0.113.10')
->assertJsonMissingPath('cluster.servers.0.sshUser')
->assertJsonMissingPath('cluster.servers.0.sshPort')
->assertJsonPath('cluster.servers.0.privateKeyName', 'Production SSH Key')
->assertJsonPath('cluster.servers.0.nodeAddress', '203.0.113.10')
->assertJsonPath('cluster.servers.0.builderEnabled', true)
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%')
->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'builder'])
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821')
->assertJsonPath('cluster.servers.0.wireguardManagementIp', null)
->assertJsonPath('cluster.servers.0.containerSubnets', []);
expect(V5Server::query()
->where('team_id', $team->id)
->where('cluster_id', $cluster->id)
->where('created_by_user_id', $user->id)
->where('name', 'prod-01')
->where('host', '203.0.113.10')
->where('ssh_user', 'root')
->where('ssh_port', 22)
->where('private_key_id', $privateKey->id)
->where('node_address', '203.0.113.10')
->exists())->toBeTrue();
expect(V5Server::query()->where('name', 'prod-01')->first()->capabilities)
->toBe(['coold', 'builder']);
});
it('defaults dev Lima wireguard overrides for host docker internal servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Testing Host Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'coolify-naked-test',
'host' => 'host.docker.internal',
'ssh_user' => 'root',
'ssh_port' => 60003,
'private_key_id' => $privateKey->id,
])
->assertCreated()
->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51823)
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'host.lima.internal:51823');
});
it('rejects adding a v5 server to another teams cluster', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$cluster = Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $user->id,
'name' => 'Other Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
])
->assertForbidden();
});
it('validates v5 server creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => '',
'host' => '',
'ssh_user' => '',
'ssh_port' => 70000,
'private_key_id' => null,
'wireguard_listen_port_override' => 70000,
])
->assertUnprocessable()
->assertJsonValidationErrors([
'name',
'host',
'ssh_user',
'ssh_port',
'private_key_id',
'wireguard_listen_port_override',
]);
});
it('rejects private keys from another team when adding a v5 server', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherPrivateKey = createV5PrivateKey($otherTeam, 'Other SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers", [
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_id' => $otherPrivateKey->id,
])
->assertUnprocessable()
->assertJsonValidationErrors(['private_key_id']);
});
it('checks v5 server ssh status and stores diagnostic output', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/check")
->assertSuccessful()
->assertJsonPath('cluster.servers.0.status', 'pending')
->assertJsonPath('cluster.servers.0.lastStatusCheck', 'reachable')
->assertJsonPath('cluster.servers.0.lastStatusOutput', "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker");
Process::assertRan(fn ($process) => str_contains(json_encode($process->command), '203.0.113.10'));
Process::assertRan(fn ($process) => in_array('LogLevel=ERROR', $process->command, true));
$server->refresh();
expect($server->status)->toBe('pending')
->and($server->last_status_check)->toBe('reachable')
->and($server->last_status_output)->toContain('SSH connection OK')
->and($server->last_status_checked_at)->not->toBeNull();
});
it('writes quiet ssh options for v5 bootstrap ssh config', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
]);
$server->load('privateKey');
$tempDirectory = storage_path('framework/testing/v5_bootstrap_'.str()->random(8));
mkdir($tempDirectory, 0700, true);
try {
$controller = app(DashboardController::class);
$reflection = new ReflectionClass($controller);
$method = $reflection->getMethod('writeBootstrapSshConfig');
$method->setAccessible(true);
$sshConfigLocation = $method->invoke($controller, collect([$server]), $tempDirectory);
$config = file_get_contents($sshConfigLocation);
expect($config)->toContain(' LogLevel ERROR')
->and($config)->toContain(' UserKnownHostsFile /dev/null')
->and($config)->toContain(' StrictHostKeyChecking no');
} finally {
collect(scandir($tempDirectory) ?: [])
->reject(fn (string $file) => in_array($file, ['.', '..'], true))
->each(fn (string $file) => @unlink($tempDirectory.'/'.$file));
@rmdir($tempDirectory);
}
});
it('bootstraps a single v5 server with the Coolify CLI', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
'namespaces' => ['default', 'preview'],
'container_network_pool' => '10.211.0.0/16',
'container_network_prefix' => 25,
'wireguard_management_pool' => '100.65.0.0/16',
'wireguard_interface' => 'wg-prod',
'wireguard_listen_port' => 51830,
'coold_version' => 'v0.2.0',
'corrosion_version' => 'v1.1.0',
'corrosion_gossip_port' => 8788,
'corrosion_api_port' => 8081,
'builder_enabled' => true,
'builder_capacity' => 4,
'builder_cpu_quota' => '400%',
'builder_memory_max' => '4G',
'builder_timeout_secs' => 2400,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 2222,
'status' => 'pending',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 4,
'wireguard_listen_port_override' => 51831,
'wireguard_endpoint_override' => 'prod-01.example.com:51831',
]);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertSuccessful()
->assertJsonPath('cluster.lastCliAction', 'bootstrap')
->assertJsonPath('cluster.lastCliStatus', 'succeeded')
->assertJsonPath('cluster.lastCliSummary', 'Bootstrap completed')
->assertJsonPath('cluster.servers.0.status', 'installed')
->assertJsonPath('cluster.servers.0.capabilities', ['builder', 'coold'])
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', fn (?string $value) => $value !== null);
Process::assertRan(function ($process) use ($server): bool {
$command = $process->command;
$node = "v5-server-{$server->id}";
return $command[0] === '/tmp/coolify'
&& in_array('init', $command, true)
&& in_array('bootstrap', $command, true)
&& in_array($node, $command, true)
&& in_array('--ssh-config', $command, true)
&& in_array('default,preview', $command, true)
&& in_array('10.211.0.0/16', $command, true)
&& in_array('100.65.0.0/16', $command, true)
&& in_array('wg-prod', $command, true)
&& in_array('v0.2.0', $command, true)
&& in_array('v1.1.0', $command, true)
&& in_array("{$node}=51831", $command, true)
&& in_array("{$node}=prod-01.example.com:51831", $command, true)
&& in_array('--enable-builder', $command, true)
&& in_array('--yes', $command, true)
&& ! in_array('--new-nodes', $command, true);
});
$cluster->refresh();
$server->refresh();
expect($cluster->last_cli_status)->toBe('succeeded')
->and($cluster->last_cli_summary)->toBe('Bootstrap completed')
->and($server->status)->toBe('installed')
->and($server->last_bootstrapped_at)->not->toBeNull();
});
it('does not run a macOS development Coolify CLI binary from Docker during v5 server bootstrap', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertSuccessful();
Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify');
});
it('keeps a v5 server pending when Coolify CLI bootstrap fails', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Production SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertServerError()
->assertJsonPath('message', 'bootstrap failed')
->assertJsonPath('cluster.lastCliAction', 'bootstrap')
->assertJsonPath('cluster.lastCliStatus', 'failed')
->assertJsonPath('cluster.lastCliSummary', 'bootstrap failed')
->assertJsonPath('cluster.servers.0.status', 'pending')
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null);
$server->refresh();
expect($server->status)->toBe('pending')
->and($server->last_bootstrapped_at)->toBeNull();
});
it('extends a v5 cluster when bootstrapping a new server', function () {
createSharedUserAndTeamTables();
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
[$user, $team] = createV5UserWithTeam();
$oldPrivateKey = createV5PrivateKey($team, 'Old SSH Key');
$newPrivateKey = createV5PrivateKey($team, 'New SSH Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$oldServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $oldPrivateKey->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now()->subDay(),
]);
$newServer = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $newPrivateKey->id,
'name' => 'prod-02',
'host' => '203.0.113.11',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: "Extend completed\n"),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$newServer->id}/bootstrap")
->assertSuccessful()
->assertJsonPath('cluster.lastCliAction', 'extend')
->assertJsonPath('cluster.lastCliStatus', 'succeeded')
->assertJsonPath('cluster.lastCliSummary', 'Extend completed')
->assertJsonPath('cluster.servers.0.status', 'installed')
->assertJsonPath('cluster.servers.1.status', 'installed');
Process::assertRan(function ($process) use ($oldServer, $newServer): bool {
$command = $process->command;
$oldNode = "v5-server-{$oldServer->id}";
$newNode = "v5-server-{$newServer->id}";
return in_array('extend', $command, true)
&& in_array("{$oldNode},{$newNode}", $command, true)
&& in_array('--new-nodes', $command, true)
&& in_array($newNode, $command, true)
&& in_array('--ssh-config', $command, true);
});
$oldServer->refresh();
$newServer->refresh();
expect($oldServer->status)->toBe('installed')
->and($newServer->status)->toBe('installed')
->and($newServer->last_bootstrapped_at)->not->toBeNull();
});
it('deletes an unbootstrapped v5 server from a cluster', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'pending',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}")
->assertSuccessful()
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(V5Server::query()->whereKey($server->id)->exists())->toBeFalse();
});
it('does not delete a bootstrapped v5 server', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson("/v5/clusters/{$cluster->id}/servers/{$server->id}")
->assertConflict()
->assertJsonPath('message', 'Only unbootstrapped servers can be deleted.');
expect(V5Server::query()->whereKey($server->id)->exists())->toBeTrue();
});
it('updates editable v5 server builder details without changing networking', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold'],
'builder_enabled' => false,
'builder_capacity' => 0,
'builder_cpu_quota' => '100%',
'node_address' => '10.0.0.10',
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'prod-01.example.com:51821',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => true,
'builder_capacity' => 5,
'builder_cpu_quota' => '350%',
'host' => '198.51.100.99',
'ssh_user' => 'admin',
'ssh_port' => 2222,
'node_address' => '10.0.0.99',
'wireguard_endpoint_override' => 'changed.example.com:51821',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.builderEnabled', true)
->assertJsonPath('cluster.servers.0.builderCapacity', 5)
->assertJsonPath('cluster.servers.0.builderCpuQuota', '350%')
->assertJsonPath('cluster.servers.0.host', '203.0.113.10')
->assertJsonMissingPath('cluster.servers.0.sshUser')
->assertJsonMissingPath('cluster.servers.0.sshPort')
->assertJsonPath('cluster.servers.0.nodeAddress', '10.0.0.10')
->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821');
$server->refresh();
expect($server->builder_enabled)->toBeTrue()
->and($server->builder_capacity)->toBe(5)
->and($server->builder_cpu_quota)->toBe('350%')
->and($server->capabilities)->toBe(['coold', 'builder'])
->and($server->host)->toBe('203.0.113.10')
->and($server->ssh_user)->toBe('root')
->and($server->ssh_port)->toBe(22)
->and($server->node_address)->toBe('10.0.0.10')
->and($server->wireguard_endpoint_override)->toBe('prod-01.example.com:51821');
});
it('validates editable v5 server builder details', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$server = V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'name' => 'prod-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'builder_cpu_quota' => '200%',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => true,
'builder_capacity' => 1001,
'builder_cpu_quota' => str_repeat('a', 33),
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity', 'builder_cpu_quota']);
});
it('validates v5 cluster creation input', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => '',
'description' => str_repeat('a', 1001),
])
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'description']);
});
it('rejects duplicate v5 cluster names in the same team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Production Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => null,
])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
it('deletes an empty v5 cluster in the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Empty Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertNoContent();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse();
});
it('does not delete a v5 cluster that has servers', function () {
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' => null,
]);
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])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertUnprocessable()
->assertJsonPath('message', 'Only empty clusters can be deleted.');
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('does not delete a v5 cluster outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other-delete@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$cluster = Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Other Mesh',
'description' => null,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->deleteJson('/v5/clusters/'.$cluster->id)
->assertNotFound();
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
});
it('allows the same v5 cluster name in another team without leaking it', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$otherUser = User::withoutEvents(fn () => User::query()->create([
'name' => 'Other User',
'email' => 'other@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
Cluster::query()->create([
'team_id' => $otherTeam->id,
'created_by_user_id' => $otherUser->id,
'name' => 'Production Mesh',
'description' => 'Other team cluster.',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Production Mesh',
'description' => 'Current team cluster.',
])
->assertCreated()
->assertJsonPath('cluster.description', 'Current team cluster.');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/clusters')
->assertSuccessful()
->assertSee('Current team cluster.')
->assertDontSee('Other team cluster.');
});
it('shares existing projects and environments with the v5 dashboard page', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production');
createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging');
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"projects":[', false)
->assertSee('"name":"gravy-truck"', false)
->assertSee('"uuid":"'.$project->uuid.'"', false)
->assertSee('"environments":[', false)
->assertSee('"name":"production"', false)
->assertSee('"uuid":"'.$environment->uuid.'"', false)
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false)
->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false)
->assertDontSee($otherProject->name)
->assertDontSee($otherProject->uuid)
->assertDontSee($otherEnvironment->name)
->assertDontSee($otherEnvironment->uuid);
});
it('persists the selected v5 project and environment in the session', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5ProjectWithEnvironment($team, 'alpha-project', 'production');
[$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $selectedProject->uuid,
'environment_uuid' => $selectedEnvironment->uuid,
])
->assertNoContent()
->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid)
->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false);
});
it('rejects persisted v5 selections outside the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other V5 Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/selection', [
'project_uuid' => $otherProject->uuid,
'environment_uuid' => $otherEnvironment->uuid,
])
->assertForbidden()
->assertSessionMissing('v5.selectedProjectUuid')
->assertSessionMissing('v5.selectedEnvironmentUuid');
});
it('defines the v5 dashboard page as a shadcn styled canvas shell', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
$app = file_get_contents(resource_path('js/v5/app.tsx'));
$navbarPath = resource_path('js/v5/components/app-navbar.tsx');
expect(file_exists($navbarPath))->toBeTrue();
$navbar = file_get_contents($navbarPath);
$sheetPath = resource_path('js/v5/components/ui/sheet.tsx');
expect(file_exists($sheetPath))->toBeTrue();
expect($app)
->toContain('progress: {')
->toContain('delay: 10')
->toContain("color: '#fcd452'")
->toContain('showSpinner: false')
->not->toContain('TopNavigationLoadingIndicator')
->not->toContain('withApp:');
expect($dashboardPage)
->toContain('Dashboard')
->toContain("import { AppNavbar } from '@/components/app-navbar';")
->not->toContain('function csrfToken()')
->not->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain("fetch('/v5/clusters'")
->toContain('<AppNavbar')
->toContain('bg-background text-foreground')
->toContain('h-dvh overflow-hidden bg-background text-foreground')
->toContain('flex h-full min-h-0 items-center justify-center overflow-hidden px-6 pt-16')
->not->toContain('<header')
->not->toContain('h-[calc(100dvh-4rem)]')
->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground')
->toContain('This is where the magic happens.')
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->not->toContain("fetch('/v5/selection'");
expect($navbar)
->toContain("import { Link, usePage } from '@inertiajs/react';")
->toContain("import { cn } from '@/lib/utils';")
->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->not->toContain('function csrfToken()')
->toContain('export function AppNavbar')
->toContain('/coolify-logo.svg')
->toContain('<Link')
->toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"')
->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"')
->toContain('bg-muted/40')
->toContain('text-muted-foreground')
->toContain('SelectGroup')
->toContain('variant="ghost"')
->toContain('position="popper"')
->toContain('sideOffset={4}')
->toContain('Select a project')
->toContain('Select an environment')
->toContain('const { url } = usePage();')
->toContain("const isClustersPage = url.startsWith('/v5/clusters');")
->toContain('href="/v5"')
->toContain('href="/v5/clusters"')
->toContain('Clusters')
->toContain('className="relative flex h-16 items-center gap-3 px-4 sm:px-6"')
->toContain('className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2"')
->toContain('className="max-w-[38vw] md:max-w-[10rem]"')
->toContain('className="max-w-[30vw] md:max-w-[10rem]"')
->toContain('aria-label="Open mobile menu"')
->toContain('<Sheet>')
->toContain('<SheetTrigger')
->toContain('<SheetContent side="right" className="w-72 max-w-[85vw] bg-background"')
->toContain('<SheetHeader>')
->toContain('<SheetTitle>Coolify</SheetTitle>')
->toContain('<SheetDescription className="sr-only">')
->toContain('<SheetClose')
->toContain('Move between Coolify v5 pages.')
->not->toContain('<SheetTitle className="sr-only">Navigation</SheetTitle>')
->toContain('className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"')
->toContain('className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"')
->toContain('Dashboard')
->not->toContain('Home')
->toContain("fetch('/v5/selection'")
->toContain("'X-CSRF-TOKEN': csrfToken()")
->not->toContain('isMobileMenuOpen')
->not->toContain('setIsMobileMenuOpen')
->not->toContain('isProjectEnvironmentMenuOpen')
->not->toContain('setIsProjectEnvironmentMenuOpen')
->not->toContain('Open project and environment selector')
->not->toContain('Close project and environment selector')
->not->toContain('DropdownMenu')
->not->toContain('fixed inset-0 bg-black/80')
->not->toContain('fixed inset-y-0 right-0')
->not->toContain('<a')
->not->toContain("import { Button } from '@/components/ui/button';")
->not->toContain('<Button')
->not->toContain("import { Separator } from '@/components/ui/separator';")
->not->toContain('<Separator')
->not->toContain('min-h-[calc(100vh-4rem)]')
->not->toContain('py-10')
->not->toContain('bg-background/95')
->not->toContain('backdrop-blur')
->not->toContain('supports-[backdrop-filter]')
->not->toContain('border-coolgray')
->not->toContain('className="w-[12rem]"')
->not->toContain('<h1>Coolify v5</h1>')
->not->toContain('<h2 id="clusters-heading">Clusters</h2>');
expect(file_exists(resource_path('js/v5/components/top-navigation-loading-indicator.tsx')))->toBeFalse();
});
it('tracks v5 server actions with reusable per-id loading state', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
$pendingIdsHook = file_get_contents(resource_path('js/v5/lib/use-pending-ids.ts'));
expect($clustersPage)
->toContain("import { usePendingIds } from '@/lib/use-pending-ids';")
->toContain('const checkingServers = usePendingIds<string>()')
->toContain('const isCheckingServer = checkingServers.has(server.id)')
->toContain('checkingServers.start(server.id)')
->toContain('checkingServers.finish(server.id)')
->not->toContain('const [checkingServerId, setCheckingServerId] = useState<string | null>(null)')
->not->toContain('setCheckingServerId(server.id)')
->not->toContain('setCheckingServerId(null)');
expect($pendingIdsHook)
->toContain('export function usePendingIds')
->toContain('const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set())')
->toContain('start')
->toContain('finish')
->toContain('has')
->toContain('hasAny');
});
it('defines the v5 cluster management page and create cluster form', function () {
$clustersPagePath = resource_path('js/v5/Pages/Clusters.tsx');
$clustersPage = file_get_contents($clustersPagePath);
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
$dialogComponent = file_get_contents(resource_path('js/v5/components/ui/dialog.tsx'));
$types = file_get_contents(resource_path('js/v5/types.ts'));
expect(file_exists($clustersPagePath))->toBeTrue();
expect($clustersPage)
->toContain("import { Button } from '@/components/ui/button';")
->toContain("} from '@/components/ui/dialog';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->toContain("fetch('/v5/clusters'")
->toContain('aria-label="Create cluster"')
->toContain('setIsCreateDialogOpen(true)')
->toContain('Add cluster')
->toContain('variant="coolify"')
->toContain('border-warning bg-warning/10 text-foreground')
->not->toContain('border-primary bg-primary/10 text-foreground')
->toContain('<Dialog')
->toContain('<DialogTitle>Create cluster</DialogTitle>')
->toContain('<DialogDescription>')
->toContain('<DialogFooter>')
->not->toContain('<DialogClose')
->toContain('Cancel')
->toContain('<DialogContent className="max-w-md" showCloseButton={false}>')
->toContain('<DialogTitle>Confirm deletion</DialogTitle>')
->toContain('variant="delete"')
->toContain('aria-label="Add server to cluster"')
->toContain('setIsAddServerDialogOpen(true)')
->toContain('Add server')
->toContain('className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"')
->toContain('<DialogTitle>Add server</DialogTitle>')
->toContain('DialogContent className="max-w-3xl"')
->toContain('DialogContent className="max-w-2xl"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto"')
->not->toContain('DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto"')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`')
->toContain('Bootstrap')
->toContain('lastCliSummary')
->toContain('Unable to bootstrap this server. Check the CLI state output.')
->toContain('border-destructive/30')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers`')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`')
->toContain('fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`')
->toContain('fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`')
->toContain("method: 'PATCH'")
->toContain("method: 'DELETE'")
->toContain('Check SSH')
->toContain('Bootstrap: {server.status}')
->toContain('Latest SSH check')
->toContain('Delete')
->toContain('<DialogTitle>Edit server</DialogTitle>')
->toContain('Edit server')
->toContain('Save server')
->toContain('editServerBuilderCapacity')
->toContain('editServerBuilderCpuQuota')
->toContain('Networking and bootstrap settings stay locked after creation.')
->toContain('Bootstrap SSH user')
->toContain('Bootstrap SSH port')
->not->toContain('{server.sshUser}@{server.host}:{server.sshPort}')
->toContain('showAdvancedServerConfiguration')
->toContain('wireguardListenPortOverride')
->toContain('wireguardEndpointOverride')
->toContain('privateKeys')
->toContain('selectedPrivateKeyId')
->toContain('Private key')
->toContain('Select a private key')
->toContain('appearance-none')
->toContain('backgroundImage: `url("data:image/svg+xml')
->not->toContain('No private key')
->toContain('Create cluster')
->toContain('Cluster details')
->toContain('Servers in this cluster')
->toContain('selectedCluster')
->toContain('builderCapacity')
->toContain('privateKeyName')
->toContain('lastBootstrappedAt')
->toContain('deleteCluster')
->toContain('fetch(`/v5/clusters/${cluster.id}`')
->toContain("method: 'DELETE'")
->toContain('Delete cluster')
->toContain('selectedCluster.serversCount === 0')
->toContain('Only empty clusters can be deleted.')
->toContain('min-h-dvh overflow-y-auto bg-background text-foreground lg:h-dvh lg:overflow-hidden')
->toContain('flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6')
->toContain('flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0')
->toContain('overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto')
->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end')
->toContain('mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2')
->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]')
->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]')
->not->toContain('New cluster')
->not->toContain('<aside className="rounded-lg border border-border bg-card p-5">')
->not->toContain('This is where the magic happens.');
expect(substr_count($clustersPage, 'variant="coolify"'))->toBe(5)
->and($buttonComponent)
->toContain('coolify:')
->toContain('bg-coollabs-50')
->toContain('hover:bg-coollabs')
->toContain('delete:');
expect($dialogComponent)
->toContain('@base-ui/react/dialog')
->toContain('z-50')
->toContain('max-h-[calc(100dvh-2rem)]')
->toContain('overflow-y-auto')
->toContain('top-1/2')
->toContain('-translate-y-1/2')
->not->toContain('top-4 bottom-4')
->not->toContain('top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2')
->toContain('showCloseButton = true')
->toContain('aria-label="Close dialog"')
->toContain('<XIcon />')
->toContain('DialogTitle')
->toContain('DialogDescription');
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
expect($v5Css)
->toContain('--color-warning: var(--warning);')
->toContain('--warning: #fcd452;')
->not->toContain('--ring: oklch(0.705 0.015 286.067);')
->not->toContain('--ring: oklch(0.552 0.016 285.938);');
expect(substr_count($v5Css, '--ring: var(--warning);'))->toBe(2);
expect($types)
->not->toContain('sshUser: string;')
->not->toContain('sshPort: number;')
->toContain('builderEnabled: boolean;')
->toContain('builderCapacity: number;')
->toContain('builderCpuQuota: string;')
->toContain('privateKeyName: string | null;')
->toContain('lastBootstrappedAt: string | null;');
});
it('defines a ghost variant for compact v5 select triggers', function () {
$select = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
expect($select)
->toContain('@base-ui/react/select')
->not->toContain('radix-ui')
->toContain("variant = 'default'")
->toContain("variant === 'default'")
->toContain("variant === 'ghost'")
->toContain('border-transparent')
->toContain('h-auto')
->toContain('text-sm')
->toContain("position === 'popper' ? false : true");
});
it('provides reusable v5 form field primitives with reserved validation error space', function () {
$fieldPath = resource_path('js/v5/components/ui/field.tsx');
$inputPath = resource_path('js/v5/components/ui/input.tsx');
$textareaPath = resource_path('js/v5/components/ui/textarea.tsx');
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect(file_exists($fieldPath))->toBeTrue()
->and(file_exists($inputPath))->toBeTrue()
->and(file_exists($textareaPath))->toBeTrue();
$field = file_get_contents($fieldPath);
$input = file_get_contents($inputPath);
$textarea = file_get_contents($textareaPath);
expect($field)
->toContain('function Field(')
->toContain('function FieldLabel(')
->toContain('function FieldError(')
->toContain('min-h-4')
->toContain('aria-live="polite"')
->toContain('message ? undefined : true')
->toContain('export { Field, FieldError, FieldLabel }');
expect($input)
->toContain('function Input(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Input };');
expect($textarea)
->toContain('function Textarea(')
->toContain('aria-invalid:border-destructive')
->toContain('export { Textarea };');
expect($clustersPage)
->toContain("import { Field, FieldError, FieldLabel } from '@/components/ui/field';")
->toContain("import { Input } from '@/components/ui/input';")
->toContain("import { Textarea } from '@/components/ui/textarea';")
->toContain('<FieldError message={errors.name?.[0]} />')
->toContain('<FieldError message={serverErrors.private_key_id?.[0]} />')
->not->toContain('{errors.name ? <span className="text-xs text-destructive">{errors.name[0]}</span> : null}')
->not->toContain('{serverErrors.name ? (');
});
it('uses the requested shadcn preset configuration for v5', function () {
$components = json_decode(file_get_contents(base_path('components.json')), true);
$css = file_get_contents(resource_path('css/v5/app.css'));
expect($components['style'])
->toBe('base-lyra')
->and($components['tsx'])->toBeTrue()
->and($components['iconLibrary'])->toBe('phosphor')
->and($components['tailwind']['css'])->toBe('resources/css/v5/app.css')
->and($components['tailwind']['baseColor'])->toBe('zinc')
->and($css)->toContain('@import "@fontsource-variable/geist";')
->and($css)->toContain('--foreground: oklch(0.141 0.005 285.823);')
->and($css)->toContain('--background: #101010;')
->and($css)->toContain('button:not(:disabled)');
});
it('selects a shared team when the session has no current team', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Grace Hopper',
'email' => 'grace@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Auto Selected Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'admin']);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSessionHas('currentTeam')
->assertDontSee('Auto Selected Team')
->assertDontSee('admin');
});
it('serves v5 dev assets from the current request host', function (string $url, string $viteHost) {
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$hotFile = public_path('hot');
$originalHotFile = file_exists($hotFile) ? file_get_contents($hotFile) : null;
file_put_contents($hotFile, 'http://configured-vite-host.test:5173');
try {
$response = $this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get($url);
$response
->assertSuccessful()
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
->assertDontSee('configured-vite-host.test', false);
} finally {
if ($originalHotFile === null) {
@unlink($hotFile);
} else {
file_put_contents($hotFile, $originalHotFile);
}
}
})->with([
'localhost' => ['http://localhost:8000/v5', 'localhost'],
'tailscale ip' => ['http://100.64.0.10:8000/v5', '100.64.0.10'],
]);
it('configures the vite dev server for remote v5 access', function () {
$viteConfig = file_get_contents(base_path('vite.config.js'));
expect($viteConfig)
->toContain('host: "0.0.0.0"')
->toContain('allowedHosts: true')
->toContain('cors: true')
->toContain('const viteHmrHost = env.VITE_HMR_HOST || null;')
->toContain('hmr: viteHmrHost')
->not->toContain('hmr: viteHost');
});
it('shows when flux is unavailable', function () {
$this->withoutVite();
fakeFluxHealth(false, 'Flux socket was not found.');
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Katherine Johnson',
'email' => 'katherine@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Flux Test Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('Unavailable')
->assertSee('Flux socket was not found.');
});
it('does not include coolify version controls on the v5 dashboard page', function () {
$dashboardPage = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
expect($dashboardPage)
->not->toContain('Check coolify version')
->not->toContain('/v5/coolify/version')
->not->toContain('Installed version:');
});
it('renders flux status as a compact summary', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)
->toContain('Flux: {flux?.label ??')
->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
->toContain('{clusters.length} clusters')
->not->toContain('<h2 id="flux-status-heading">Flux status</h2>')
->not->toContain('<p>{flux.message}</p>')
->not->toContain('Socket: {flux.socket}');
});
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,
'--cluster' => 'Development-Lima',
'--builder-capacity' => 2,
'--server' => [
'coold-dev|host.docker.internal|developer|61332',
'coold-dev-2|host.docker.internal|developer|61379',
],
]);
expect($exitCode)->toBe(0)
->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue();
});
it('updates legacy dev Lima hostnames to Docker reachable SSH endpoints', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev 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',
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--cluster' => 'Development-Lima',
'--builder-capacity' => 2,
'--server' => [
'coold-dev|host.docker.internal|developer|61332',
],
]);
expect($exitCode)->toBe(0)
->and(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->exists())->toBeTrue()
->and(V5Server::query()->where('host', 'lima-coold-dev')->exists())->toBeFalse();
});
it('seeds dev Lima VMs into v5 clusters and servers idempotently', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
createV5PrivateKey($team, 'Dev Lima Key');
config()->set('coold.dev_builder_capacity', 2);
(new V5DevLimaSeeder)->run();
(new V5DevLimaSeeder)->run();
$cluster = Cluster::query()->where('name', 'Development-Lima')->sole();
expect($cluster->team_id)->toBe($team->id)
->and($cluster->created_by_user_id)->toBe($user->id)
->and($cluster->description)->toBe('Local Lima development cluster managed by scripts/dev.sh.')
->and(V5Server::query()->count())->toBe(2)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60001)->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60002)->exists())->toBeTrue()
->and(V5Server::query()->where('status', 'installed')->count())->toBe(2)
->and(V5Server::query()->where('builder_enabled', true)->where('builder_capacity', 2)->count())->toBe(2)
->and(V5Server::query()->where('cluster_id', $cluster->id)->count())->toBe(2);
});
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) {
$mock->shouldReceive('check')
->once()
->andReturn([
'available' => $available,
'label' => $available ? 'Running' : 'Unavailable',
'message' => $message,
'socket' => '/run/coolify/flux.sock',
]);
}));
}
function createSharedUserAndTeamTables(): void
{
Schema::create('users', function ($table) {
$table->id();
$table->string('name')->default('Anonymous');
$table->string('email');
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('teams', function ($table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->boolean('personal_team')->default(false);
$table->boolean('show_boarding')->default(false);
$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('projects', function ($table) {
$table->id();
$table->string('uuid');
$table->string('name');
$table->text('description')->nullable();
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('environments', function ($table) {
$table->id();
$table->string('name');
$table->foreignId('project_id');
$table->timestamps();
$table->text('description')->nullable();
$table->string('uuid');
});
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->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();
});
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->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_status_check')->nullable();
$table->text('last_status_output')->nullable();
$table->timestamp('last_status_checked_at')->nullable();
$table->timestamps();
});
Schema::create('team_user', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('user_id');
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
}
/**
* @return array{0: Project, 1: Environment}
*/
function createV5ProjectWithEnvironment(Team $team, string $projectName, string $environmentName): array
{
$project = Project::withoutEvents(fn () => Project::query()->forceCreate([
'uuid' => str($projectName)->slug().'-uuid',
'name' => $projectName,
'description' => null,
'team_id' => $team->id,
]));
$environment = Environment::withoutEvents(fn () => Environment::query()->forceCreate([
'uuid' => str($environmentName)->slug().'-uuid',
'name' => $environmentName,
'description' => null,
'project_id' => $project->id,
]));
return [$project, $environment];
}
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}
*/
function createV5UserWithTeam(): array
{
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Margaret Hamilton',
'email' => 'margaret@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Tooling Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
return [$user, $team];
}