Files
coolify/tests/Feature/V5/DashboardTest.php
T
Andras Bacsai 155b07378c refactor(v5): remove CLI-specific fields and labels from clusters UI
Remove server count badge, last run status, and CLI summary from the
cluster detail panel. Rename "CLI state" to "Cluster state" and update
the add-server dialog to drop "CLI-generated" wording. Add test
assertions covering all removed and renamed elements.
2026-06-19 13:53:10 +02:00

2735 lines
107 KiB
PHP

<?php
use App\Events\V5ClusterUpdated;
use App\Events\V5RealtimeTestEvent;
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\Jobs\V5BootstrapServerJob;
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\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
beforeEach(function () {
Config::set('app.maintenance.store', 'array');
Config::set('broadcasting.default', 'log');
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.show'))->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.realtime-test'))->toBeTrue()
->and(Route::has('v5.realtime-test.broadcast'))->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();
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();
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',
'uuid',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'last_bootstrapped_at',
'last_bootstrap_action',
'last_bootstrap_status',
'last_bootstrap_output',
'last_bootstrap_ran_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('keeps v5 server fields in the initial migration', 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();
expect(Schema::hasColumns('v5_servers', [
'uuid',
'builder_cpu_quota',
'node_address',
'wireguard_management_ip',
'container_subnets',
'last_bootstrap_output',
'last_status_output',
]))->toBeTrue();
});
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('"uuid" TEXT')
->and($schema)->toContain('"wireguard_management_ip" TEXT')
->and($schema)->toContain('"container_subnets" JSON')
->and($schema)->toContain('"last_bootstrap_output" TEXT')
->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)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers')
->and($schema)->not->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table')
->and($schema)->not->toContain('2026_06_17_172845_add_status_check_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_064539_add_bootstrap_log_fields_to_v5_servers_table')
->and($schema)->not->toContain('2026_06_19_090217_add_uuid_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('js/echo.js', false)
->assertSee('js/pusher.js', false)
->assertSee('window.Echo', 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')
->assertSee('currentTeam', false)
->assertDontSee('teams', false)
->assertDontSee('V5 Shared Team')
->assertDontSee('Shared team details');
});
it('broadcasts v5 cluster updates when bootstrap state changes', 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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
Event::fake([V5ClusterUpdated::class]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertAccepted();
Event::assertDispatched(V5ClusterUpdated::class, fn ($event): bool => $event->clusterId === $cluster->id
&& $event->teamId === $team->id);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
expect(Event::dispatched(V5ClusterUpdated::class)->count())->toBeGreaterThanOrEqual(2);
});
it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', 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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
'last_bootstrap_action' => 'bootstrap',
'last_bootstrap_status' => 'running',
'last_bootstrap_output' => 'Starting Coolify CLI bootstrap for prod-01...',
'last_bootstrap_ran_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->getJson("/v5/clusters/{$cluster->id}")
->assertSuccessful()
->assertJsonPath('cluster.id', (string) $cluster->id)
->assertJsonPath('cluster.servers.0.id', (string) $server->id)
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running')
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...');
});
it('broadcasts v5 cluster updates immediately with an explicit echo event name', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
$event = new V5ClusterUpdated(1, 1);
expect($event)->toBeInstanceOf(ShouldBroadcastNow::class)
->and($event->broadcastAs())->toBe('v5.cluster.updated')
->and($clustersPage)->toContain("listen('.v5.cluster.updated'")
->and($clustersPage)->toContain('Waiting for window.Echo before subscribing to cluster updates');
});
it('serves a v5 realtime test page for manual websocket diagnostics', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$page = file_get_contents(resource_path('js/v5/Pages/RealtimeTest.tsx'));
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5/realtime-test')
->assertSuccessful()
->assertSee('RealtimeTest', false)
->assertSee((string) $team->id, false);
expect($page)
->toContain("listen('.v5.realtime.test'")
->toContain('Subscribing to private-')
->toContain('Broadcast event');
});
it('broadcasts manual v5 realtime test events immediately', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
Event::fake([V5RealtimeTestEvent::class]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/realtime-test', [
'message' => 'hello socket',
])
->assertAccepted()
->assertJsonPath('message', 'Realtime test event broadcasted.');
Event::assertDispatched(V5RealtimeTestEvent::class, fn (V5RealtimeTestEvent $event): bool => $event->teamId === $team->id
&& $event->message === 'hello socket'
&& $event->broadcastAs() === 'v5.realtime.test'
&& $event instanceof ShouldBroadcastNow);
});
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('requires positive v5 cluster builder capacity when builders are enabled', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/clusters', [
'name' => 'Zero Builder Mesh',
'builder_enabled' => true,
'builder_capacity' => 0,
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity']);
});
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',
'coold_version' => 'v9.9.9',
'corrosion_version' => 'v8.8.8',
])
->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')
->assertJsonMissingPath('cluster.servers.0.cooldVersion')
->assertJsonMissingPath('cluster.servers.0.corrosionVersion')
->assertJsonPath('cluster.servers.0.privateKeyName', 'Production SSH Key')
->assertJsonPath('cluster.servers.0.status', 'added')
->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('keeps added v5 server builder capacity when builder is disabled', 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,
'builder_enabled' => false,
'builder_capacity' => 3,
])
->assertCreated()
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.builderCapacity', 3)
->assertJsonPath('cluster.servers.0.capabilities', ['coold']);
$server = V5Server::query()->where('name', 'prod-01')->first();
expect($server->builder_enabled)->toBeFalse()
->and($server->builder_capacity)->toBe(3)
->and($server->capabilities)->toBe(['coold']);
});
it('requires positive v5 server builder capacity when builder is enabled', 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,
]);
$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,
'builder_enabled' => true,
'builder_capacity' => 0,
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity']);
});
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 without storing 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' => 'added',
'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('status', 'reachable')
->assertJsonPath('output', "SSH connection OK\nprod-01\nLinux aarch64\n/usr/bin/docker")
->assertJsonPath('checkedAt', fn (?string $value) => $value !== null);
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('added')
->and($server->last_status_check)->toBeNull()
->and($server->last_status_output)->toBeNull()
->and($server->last_status_checked_at)->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' => 'added',
'capabilities' => ['builder'],
'builder_enabled' => true,
'builder_capacity' => 4,
'wireguard_listen_port_override' => 51831,
'wireguard_endpoint_override' => 'prod-01.example.com:51831',
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertAccepted()
->assertJsonPath('message', 'Bootstrap queued.')
->assertJsonPath('cluster.servers.0.status', 'added')
->assertJsonPath('cluster.servers.0.lastBootstrapAction', 'bootstrap')
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued')
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Queued Coolify bootstrap for prod-01.');
Queue::assertPushed(V5BootstrapServerJob::class);
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
Process::assertRan(function ($process): bool {
$command = $process->command;
$node = cliFlagValue($command, '--nodes');
return $command[0] === '/tmp/coolify'
&& in_array('init', $command, true)
&& in_array('bootstrap', $command, true)
&& in_array('--ssh-config', $command, true)
&& is_string($node)
&& str_starts_with($node, 'v5-server-')
&& cliFlagValue($command, '--namespaces') === 'default,preview'
&& cliFlagValue($command, '--container-pool') === '10.211.0.0/16'
&& cliFlagValue($command, '--wg-mgmt-pool') === '100.65.0.0/16'
&& cliFlagValue($command, '--wg-interface') === 'wg-prod'
&& cliFlagValue($command, '--coold-version') === 'v0.2.0'
&& cliFlagValue($command, '--corrosion-version') === 'v1.1.0'
&& cliFlagValue($command, '--wg-listen-port-overrides') === $node.'=51831'
&& cliFlagValue($command, '--wg-endpoint-overrides') === $node.'=prod-01.example.com:51831'
&& in_array('--enable-builder', $command, true)
&& in_array('--yes', $command, true)
&& ! in_array('--new-nodes', $command, true);
});
$cluster->refresh();
expect($server->status)->toBe('installed')
->and($server->last_bootstrap_status)->toBe('succeeded')
->and($server->last_bootstrap_output)->toBe('Bootstrap completed')
->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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertAccepted();
Process::fake([
'*' => Process::result(output: "Bootstrap completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertRan(fn ($process): bool => $process->command[0] === '/usr/local/bin/coolify');
});
it('keeps a v5 server added 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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$server->id}/bootstrap")
->assertAccepted()
->assertJsonPath('cluster.servers.0.status', 'added')
->assertJsonPath('cluster.servers.0.lastBootstrappedAt', null)
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'queued');
Process::fake([
'*' => Process::result(errorOutput: "bootstrap failed\n", exitCode: 1),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toBe('bootstrap failed')
->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,
'coold_version' => 'v0.3.0',
'corrosion_version' => 'v1.2.0',
]);
$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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Queue::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson("/v5/clusters/{$cluster->id}/servers/{$newServer->id}/bootstrap")
->assertAccepted()
->assertJsonPath('cluster.servers.1.lastBootstrapAction', 'extend')
->assertJsonPath('cluster.servers.1.lastBootstrapStatus', 'queued');
Process::fake([
'*' => Process::result(output: "Extend completed\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $newServer->id))->handle();
Process::assertRan(function ($process) use ($oldServer, $newServer): bool {
$command = $process->command;
$oldNode = "v5-server-{$oldServer->uuid}";
$newNode = "v5-server-{$newServer->uuid}";
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)
&& cliFlagValue($command, '--coold-version') === 'v0.3.0'
&& cliFlagValue($command, '--corrosion-version') === 'v1.2.0';
});
$oldServer->refresh();
$newServer->refresh();
expect($oldServer->status)->toBe('installed')
->and($newServer->status)->toBe('installed')
->and($newServer->last_bootstrap_status)->toBe('succeeded')
->and($newServer->last_bootstrap_output)->toBe('Extend completed')
->and($newServer->last_bootstrapped_at)->not->toBeNull();
});
it('adopts a re-added v5 server that is already bootstrapped for the same cluster', 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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: json_encode([
'cluster_id' => $cluster->id,
'server_uuid' => 'server-previous-uuid',
'wireguard_management_ip' => '100.64.0.10',
'wireguard_public_key' => 'public-key',
'container_subnets' => ['default' => '10.210.0.0/24'],
], JSON_THROW_ON_ERROR)."\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
$server->refresh();
expect($server->status)->toBe('installed')
->and($server->uuid)->toBe('server-previous-uuid')
->and($server->wireguard_management_ip)->toBe('100.64.0.10')
->and($server->wireguard_public_key)->toBe('public-key')
->and($server->container_subnets)->toBe(['default' => '10.210.0.0/24'])
->and($server->last_bootstrap_status)->toBe('succeeded')
->and($server->last_bootstrap_output)->toBe('Adopted existing Coolify bootstrap state for this cluster.')
->and($server->last_bootstrapped_at)->not->toBeNull();
});
it('blocks bootstrapping a v5 server that belongs to another cluster', 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' => 'added',
'builder_enabled' => false,
'builder_capacity' => 0,
]);
Process::fake([
'*' => Process::result(output: json_encode([
'cluster_id' => $cluster->id + 100,
'server_uuid' => 'server-other-uuid',
], JSON_THROW_ON_ERROR)."\n"),
]);
Event::fake([V5ClusterUpdated::class]);
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
Process::assertNotRan(fn ($process): bool => $process->command[0] === '/tmp/coolify');
$server->refresh();
expect($server->status)->toBe('added')
->and($server->last_bootstrap_status)->toBe('failed')
->and($server->last_bootstrap_output)->toContain('already bootstrapped for another cluster')
->and($server->last_bootstrapped_at)->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' => 'added',
'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('deletes a bootstrapped 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' => '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}")
->assertSuccessful()
->assertJsonPath('cluster.serversCount', 0)
->assertJsonPath('cluster.servers', []);
expect(V5Server::query()->whereKey($server->id)->exists())->toBeFalse();
});
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('keeps editable v5 server builder capacity when disabling builder', 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'],
'builder_enabled' => true,
'builder_capacity' => 5,
'builder_cpu_quota' => '350%',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => false,
'builder_capacity' => 5,
'builder_cpu_quota' => '350%',
])
->assertSuccessful()
->assertJsonPath('cluster.servers.0.builderEnabled', false)
->assertJsonPath('cluster.servers.0.builderCapacity', 5);
$server->refresh();
expect($server->builder_enabled)->toBeFalse()
->and($server->builder_capacity)->toBe(5)
->and($server->builder_cpu_quota)->toBe('350%')
->and($server->capabilities)->toBe(['coold']);
});
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('requires positive editable v5 server builder capacity when builder is enabled', 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' => '200%',
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [
'builder_enabled' => true,
'builder_capacity' => 0,
'builder_cpu_quota' => '200%',
])
->assertUnprocessable()
->assertJsonValidationErrors(['builder_capacity']);
});
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();
preg_match('/<Button\s+type="button"\s+variant="coolify"\s+aria-label="Add server to cluster"/m', $clustersPage, $addServerButtonMatches);
expect($addServerButtonMatches)->not->toBeEmpty();
expect($clustersPage)
->toContain("import { Button } from '@/components/ui/button';")
->toContain("} from '@/components/ui/dialog';")
->toContain("} from '@/components/ui/dropdown-menu';")
->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
->toContain("import { csrfToken } from '@/lib/csrf';")
->toContain("fetch('/v5/clusters'")
->toContain('aria-label="Select a cluster"')
->toContain('Cluster state')
->not->toContain('Last run')
->not->toContain('selectedCluster.lastCliStatus')
->not->toContain('selectedCluster.lastCliSummary')
->toContain('Generated mesh values are saved after bootstrap or extend runs.')
->not->toContain('CLI state')
->not->toContain('CLI-generated mesh values')
->toContain('Select a cluster')
->toContain('setSelectedClusterId(value)')
->toContain('aria-label="Create cluster"')
->toContain('setIsCreateDialogOpen(true)')
->toContain('Add cluster')
->toContain('variant="coolify"')
->not->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('fetch(`/v5/clusters/${selectedCluster.id}`')
->toContain('hasBootstrapInProgress')
->toContain('Bootstrap')
->toContain('lastBootstrapOutput')
->toContain('Unable to queue bootstrap for this server.')
->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('<DropdownMenu>')
->toContain('<DropdownMenuTrigger')
->toContain("import { DotsThreeIcon } from '@phosphor-icons/react';")
->toContain('variant="ghost"')
->toContain('size="icon-sm"')
->toContain('aria-label="Server actions"')
->toContain('<DotsThreeIcon data-icon="inline-start" weight="bold" />')
->not->toContain('>Server actions</DropdownMenuTrigger>')
->toContain('<DropdownMenuGroup>')
->toContain('<DropdownMenuSeparator />')
->toContain('Check connection')
->not->toContain('Check SSH')
->toContain('Not initialized')
->toContain('role="group"')
->toContain('aria-label="Server initialization"')
->toContain('rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10')
->toContain('variant="coolify"')
->toContain('className="rounded-r-md"')
->toContain('onClick={() => void bootstrapServer(server)}')
->not->toContain('<DropdownMenuItem
disabled={isBootstrappingServer}')
->not->toContain('Bootstrap: {serverStatusLabel(server.status)}')
->not->toContain('Last bootstrap')
->not->toContain('{formatDate(server.lastBootstrappedAt)}')
->toContain('Latest SSH check: {latestSshCheck.status}')
->toContain('const notInitializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt === null) ?? []')
->toContain('const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? []')
->toContain('Not initialized servers')
->toContain('{notInitializedServers.map(renderServerCard)}')
->toContain('{initializedServers.map(renderServerCard)}')
->toContain('{!isServerInitialized ? (')
->toContain('Show install logs')
->toContain('{canShowBootstrapLogs ? (')
->not->toContain('{!isServerInitialized && isBootstrapLogVisible ? (')
->toContain('Delete server')
->toContain('This removes it from this cluster inventory so you can add it again later.')
->not->toContain('<Button\n type="button"\n variant="outline"\n size="sm"\n disabled={isCheckingServer}')
->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('Node address override')
->toContain('Defaults to server IP')
->not->toContain('CLI node address')
->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('Server IP')
->toContain('{server.host}')
->not->toContain('<dt className="text-muted-foreground">CLI node</dt>')
->not->toContain('CLI node')
->not->toContain('{server.nodeAddress ?? server.host}')
->toContain('builderCapacity')
->toContain('{server.builderEnabled ? (')
->toContain('<dt className="text-muted-foreground">Builder CPU quota</dt>')
->toContain('server.builderCpuQuota')
->toContain(') : null}')
->toContain('privateKeyName')
->toContain('lastBootstrappedAt')
->toContain('This removes it from this cluster inventory so you can add it again later.')
->toContain('deleteCluster')
->toContain('fetch(`/v5/clusters/${cluster.id}`')
->toContain("method: 'DELETE'")
->toContain('Delete cluster')
->toContain('selectedCluster.serversCount === 0')
->not->toContain("selectedCluster.serversCount === 1 ? 'server' : 'servers'")
->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 w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6')
->toContain('rounded-lg border border-border bg-card p-4')
->toContain('aria-label="Select a cluster"')
->toContain('setSelectedClusterId(value)')
->not->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')
->not->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(6)
->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')
->toContain("'mt-6 flex justify-end gap-2'");
$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;')
->toContain('lastBootstrapStatus: string | null;')
->not->toContain('lastStatusOutput: string | null;');
});
it('uses the standard button size for the v5 delete cluster action', function () {
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
expect($clustersPage)
->toContain("variant=\"delete\"\n size=\"default\"\n onClick={openDeleteClusterDialog}")
->not->toContain("variant=\"delete\"\n size=\"sm\"\n onClick={openDeleteClusterDialog}");
});
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('does not render flux status in the v5 navbar', function () {
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
expect($navbar)
->not->toContain('Flux: {flux?.label ??')
->not->toContain('title={flux?.socket ?? flux?.message ?? undefined}')
->not->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->string('uuid')->nullable()->unique();
$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_bootstrap_action')->nullable();
$table->string('last_bootstrap_status')->nullable();
$table->text('last_bootstrap_output')->nullable();
$table->timestamp('last_bootstrap_ran_at')->nullable();
$table->string('last_status_check')->nullable();
$table->text('last_status_output')->nullable();
$table->timestamp('last_status_checked_at')->nullable();
$table->timestamps();
});
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}
*/
/**
* @param array<int, string> $command
*/
function cliFlagValue(array $command, string $flag): ?string
{
$index = array_search($flag, $command, true);
if ($index === false || ! isset($command[$index + 1])) {
return null;
}
return $command[$index + 1];
}
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];
}