feat(v5): add server clusters and CLI bootstrap

Create v5 cluster/server persistence, expose them on the home page,
and add a bootstrap endpoint backed by the coolify CLI. Add dev Lima
server sync support and update the dev script firewall flow.
This commit is contained in:
Andras Bacsai
2026-06-16 15:45:16 +02:00
parent 586588196d
commit 1e038fecae
16 changed files with 1389 additions and 108 deletions
@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server;
use Illuminate\Console\Command;
class V5SyncDevLimaServers extends Command
{
protected $signature = 'v5:sync-dev-lima-servers
{--team-id=0 : Team that owns the dev servers}
{--user-id=0 : User recorded as creator}
{--private-key-id= : Optional private key used by the dev servers}
{--cluster=Development-Lima : Cluster name for the dev Lima servers}
{--builder-capacity=2 : Builder capacity to record for each dev server}
{--server=* : Server as name|host|ssh_user|ssh_port}
{--force : Allow running outside local/development environments}';
protected $description = 'Sync development Lima VMs into the v5 server/cluster tables.';
public function handle(): int
{
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
$this->error('This command is intended for development only. Use --force to override.');
return self::FAILURE;
}
$team = Team::query()->find((int) $this->option('team-id')) ?? Team::query()->orderBy('id')->first();
$user = User::query()->find((int) $this->option('user-id')) ?? User::query()->orderBy('id')->first();
$privateKeyId = $this->option('private-key-id');
$privateKey = is_numeric($privateKeyId) ? PrivateKey::query()->find((int) $privateKeyId) : null;
if (! $team instanceof Team || ! $user instanceof User) {
$this->warn('Cannot sync dev Lima servers without an existing team and user.');
return self::SUCCESS;
}
$servers = $this->option('server');
if (! is_array($servers) || $servers === []) {
$this->warn('No dev Lima servers were provided.');
return self::SUCCESS;
}
$cluster = Cluster::query()->updateOrCreate([
'team_id' => $team->id,
'name' => (string) $this->option('cluster'),
], [
'created_by_user_id' => $user->id,
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
$builderCapacity = max(0, (int) $this->option('builder-capacity'));
$builderEnabled = $builderCapacity > 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
foreach ($servers as $server) {
$parts = explode('|', (string) $server);
if (count($parts) !== 4) {
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port.");
return self::FAILURE;
}
[$name, $host, $sshUser, $sshPort] = $parts;
Server::query()->updateOrCreate([
'team_id' => $team->id,
'host' => $host,
'ssh_port' => (int) $sshPort,
], [
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey?->id,
'name' => $name,
'ssh_user' => $sshUser,
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'last_bootstrapped_at' => now(),
]);
$this->info("Synced {$name} ({$host}:{$sshPort}).");
}
return self::SUCCESS;
}
}
+146 -17
View File
@@ -3,8 +3,12 @@
namespace App\Http\Controllers\V5;
use App\Http\Controllers\Controller;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Coold\CoolifyCliBootstrap;
use App\Services\Coold\CoolifyCliVersion;
use App\Services\Flux\FluxHealth;
use Illuminate\Http\JsonResponse;
@@ -23,7 +27,9 @@ class HomeController extends Controller
return Inertia::render('Home', [
'status' => 'v5-ready',
'flux' => $fluxHealth->check(),
'cooldHosts' => $this->cooldHosts(),
'clusters' => $this->clusters($currentTeam),
'cooldServers' => $this->cooldServers($currentTeam),
'privateKeys' => $this->privateKeys($currentTeam),
'currentTeam' => $currentTeam instanceof Team ? [
'id' => $currentTeam->id,
'name' => $currentTeam->name,
@@ -50,27 +56,150 @@ class HomeController extends Controller
return response()->json($coolifyCliVersion->check());
}
/**
* @return array<int, array{id: string, wireguardIp: string|null, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int}>
*/
private function cooldHosts(): array
public function bootstrapCoolify(Request $request, CoolifyCliBootstrap $coolifyCliBootstrap): JsonResponse
{
$baseId = (string) config('coold.dev_host_id');
$count = max(0, (int) config('coold.dev_host_count'));
$builderCapacity = (int) config('coold.dev_builder_capacity');
$builderEnabled = $builderCapacity > 0;
$currentTeam = $request->attributes->get('v5.currentTeam');
$validated = $request->validate([
'host' => ['required', 'string', 'max:255'],
'ssh_user' => ['required', 'string', 'max:64'],
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
'private_key_uuid' => ['required', 'string'],
'wg_listen_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'wg_endpoint' => ['nullable', 'string', 'max:255'],
'enable_builder' => ['boolean'],
'builder_capacity' => ['nullable', 'integer', 'min:0', 'max:100'],
]);
if ($count === 0 || $baseId === '') {
if (! $currentTeam instanceof Team) {
abort(403);
}
$privateKey = PrivateKey::query()
->where('team_id', $currentTeam->id)
->where('uuid', $validated['private_key_uuid'])
->first();
if (! $privateKey instanceof PrivateKey) {
return response()->json([
'successful' => false,
'label' => 'Private key unavailable',
'message' => 'The selected private key is not available for the current team.',
'output' => null,
'errorOutput' => null,
'exitCode' => null,
], 403);
}
$result = $coolifyCliBootstrap->run($validated, $privateKey);
if ($result['successful']) {
$this->recordBootstrappedServer($request->user(), $currentTeam, $privateKey, $validated);
}
return response()->json($result, $result['successful'] ? 200 : 500);
}
/**
* @param array{host: string, ssh_user: string, ssh_port: int, enable_builder?: bool, builder_capacity?: int|null} $input
*/
private function recordBootstrappedServer(User $user, Team $team, PrivateKey $privateKey, array $input): void
{
$builderEnabled = (bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true));
$builderCapacity = $builderEnabled ? (int) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2)) : 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
V5Server::query()->updateOrCreate([
'team_id' => $team->id,
'host' => $input['host'],
'ssh_port' => $input['ssh_port'],
], [
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => $input['host'],
'ssh_user' => $input['ssh_user'],
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'last_bootstrapped_at' => now(),
]);
}
/**
* @return array<int, array{uuid: string, name: string}>
*/
private function privateKeys(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return collect(range(1, $count))
->map(fn (int $index) => [
'id' => $index === 1 ? $baseId : (string) config("coold.dev_host_id_{$index}", "{$baseId}-{$index}"),
'wireguardIp' => (string) config("coold.dev_wireguard_ip_{$index}") ?: null,
'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'],
'builderEnabled' => $builderEnabled,
'builderCapacity' => $builderCapacity,
return PrivateKey::query()
->where('team_id', $currentTeam->id)
->select('uuid', 'name')
->orderBy('name')
->get()
->map(fn (PrivateKey $privateKey) => [
'uuid' => $privateKey->uuid,
'name' => $privateKey->name,
])
->all();
}
/**
* @return array<int, array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, status: string, capabilities: array<int, string>}>}>
*/
private function clusters(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return V5Cluster::query()
->where('team_id', $currentTeam->id)
->with(['servers' => fn ($query) => $query->orderBy('name')])
->withCount('servers')
->orderBy('name')
->get()
->map(fn (V5Cluster $cluster) => [
'id' => (string) $cluster->id,
'name' => $cluster->name,
'description' => $cluster->description,
'serversCount' => $cluster->servers_count,
'servers' => $cluster->servers->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
])->all(),
])
->all();
}
/**
* @return array<int, array{id: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, lastBootstrappedAt: string|null}>
*/
private function cooldServers(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return V5Server::query()
->where('team_id', $currentTeam->id)
->orderBy('name')
->get()
->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'host' => $server->host,
'sshUser' => $server->ssh_user,
'sshPort' => $server->ssh_port,
'status' => $server->status,
'capabilities' => $server->capabilities ?? [],
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toISOString(),
])
->all();
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Cluster extends V5Model
{
protected $table = 'v5_clusters';
protected $fillable = [
'team_id',
'created_by_user_id',
'name',
'description',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models\V5;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Server extends V5Model
{
protected $table = 'v5_servers';
protected $fillable = [
'team_id',
'cluster_id',
'created_by_user_id',
'private_key_id',
'name',
'host',
'ssh_user',
'ssh_port',
'status',
'capabilities',
'builder_enabled',
'builder_capacity',
'last_bootstrapped_at',
];
protected function casts(): array
{
return [
'capabilities' => 'array',
'builder_enabled' => 'boolean',
'last_bootstrapped_at' => 'datetime',
];
}
public function cluster(): BelongsTo
{
return $this->belongsTo(Cluster::class);
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function privateKey(): BelongsTo
{
return $this->belongsTo(PrivateKey::class);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Coold;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\Process;
use Throwable;
class CoolifyCliBootstrap
{
/**
* @param array{host: string, ssh_user: string, ssh_port: int, wg_listen_port?: int|null, wg_endpoint?: string|null, enable_builder?: bool, builder_capacity?: int|null} $input
* @return array{successful: bool, label: string, message: string, output: string|null, errorOutput: string|null, exitCode: int|null}
*/
public function run(array $input, PrivateKey $privateKey): array
{
$binary = $this->stringConfig('coold.coolify_cli_bin', '/usr/local/bin/coolify');
$sshKeyPath = $this->writeTemporaryPrivateKey($privateKey);
try {
$result = Process::timeout(300)->run($this->command($binary, $input, $sshKeyPath));
} catch (Throwable $exception) {
return [
'successful' => false,
'label' => 'Bootstrap failed',
'message' => $exception->getMessage(),
'output' => null,
'errorOutput' => null,
'exitCode' => null,
];
} finally {
@unlink($sshKeyPath);
}
$output = trim($result->output());
$errorOutput = trim($result->errorOutput());
return [
'successful' => $result->successful(),
'label' => $result->successful() ? 'Bootstrap finished' : 'Bootstrap failed',
'message' => $result->successful()
? 'coolify init bootstrap completed successfully.'
: ($errorOutput !== '' ? $errorOutput : 'coolify init bootstrap failed.'),
'output' => $output !== '' ? $output : null,
'errorOutput' => $errorOutput !== '' ? $errorOutput : null,
'exitCode' => $result->exitCode(),
];
}
private function command(string $binary, array $input, string $sshKeyPath): string
{
$node = sprintf('%s:%d', $input['host'], $input['ssh_port']);
$parts = [
$binary,
'init',
'bootstrap',
'--nodes',
$node,
'--ssh-key',
$sshKeyPath,
'--ssh-user',
$input['ssh_user'],
];
if (! empty($input['wg_listen_port'])) {
$this->appendOptional($parts, '--wg-listen-port-overrides', sprintf('%s=%d', $node, $input['wg_listen_port']));
}
if (! empty($input['wg_endpoint'])) {
$this->appendOptional($parts, '--wg-endpoint-overrides', sprintf('%s=%s', $node, $input['wg_endpoint']));
}
$this->appendOptional($parts, '--coold-version', $this->stringConfig('coold.coold_version', 'nightly'));
$this->appendOptional($parts, '--corrosion-version', $this->stringConfig('coold.corrosion_version', 'v1.0.0'));
if ((bool) ($input['enable_builder'] ?? config('coold.dev_builder_enabled', true))) {
$parts[] = '--enable-builder';
$this->appendOptional($parts, '--builder-capacity', (string) ($input['builder_capacity'] ?? config('coold.dev_builder_capacity', 2)));
}
$parts[] = '--yes';
return collect($parts)
->map(fn (string $part) => escapeshellarg($part))
->implode(' ');
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$directory = storage_path('app/private/coolify-cli');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
$path = tempnam($directory, 'ssh-key-');
file_put_contents($path, $privateKey->private_key);
chmod($path, 0600);
return $path;
}
/**
* @param array<int, string> $parts
*/
private function appendOptional(array &$parts, string $option, string $value): void
{
if ($value === '') {
return;
}
$parts[] = $option;
$parts[] = $value;
}
private function stringConfig(string $key, ?string $default = null): string
{
$value = config($key, $default);
return is_string($value) ? trim($value) : '';
}
}
+2 -5
View File
@@ -2,11 +2,8 @@
return [
'coolify_cli_bin' => env('COOLIFY_CLI_BIN', '/usr/local/bin/coolify'),
'dev_host_count' => (int) env('COOLIFY_COOLD_VM_COUNT', 2),
'dev_host_id' => env('COOLIFY_COOLD_DEV_HOST_ID', 'coold-dev'),
'dev_host_id_2' => env('COOLIFY_COOLD_LIMA_INSTANCE_2', env('COOLIFY_COOLD_DEV_HOST_ID', 'coold-dev').'-2'),
'dev_wireguard_ip_1' => env('COOLIFY_COOLD_VM_WG_IP_1', '100.64.0.1'),
'dev_wireguard_ip_2' => env('COOLIFY_COOLD_VM_WG_IP_2', '100.64.0.2'),
'coold_version' => env('COOLIFY_COOLD_VERSION', 'nightly'),
'corrosion_version' => env('COOLIFY_CORROSION_VERSION', 'v1.0.0'),
'dev_builder_capacity' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2),
'dev_builder_enabled' => (int) env('COOLIFY_COOLD_VM_BUILDER_CAPACITY', 2) > 0,
];
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_clusters', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
$table->unique(['team_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_clusters');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_servers', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('private_key_id')->nullable()->constrained('private_keys')->nullOnDelete();
$table->string('name');
$table->string('host');
$table->string('ssh_user');
$table->unsignedInteger('ssh_port')->default(22);
$table->string('status')->default('installed');
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
$table->timestamp('last_bootstrapped_at')->nullable();
$table->timestamps();
$table->unique(['team_id', 'host', 'ssh_port']);
$table->index(['team_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_servers');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('cluster_id')
->nullable()
->after('team_id')
->constrained('v5_clusters')
->nullOnDelete();
$table->index(['team_id', 'cluster_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropIndex(['team_id', 'cluster_id']);
$table->dropConstrainedForeignId('cluster_id');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('private_key_id')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->foreignId('private_key_id')->nullable(false)->change();
});
}
};
+35 -1
View File
@@ -689,7 +689,7 @@ CREATE TABLE IF NOT EXISTS "servers" (
"port" INTEGER DEFAULT 22 NOT NULL,
"user" TEXT DEFAULT 'root' NOT NULL,
"team_id" INTEGER NOT NULL,
"private_key_id" INTEGER NOT NULL,
"private_key_id" INTEGER,
"proxy" TEXT,
"created_at" TEXT,
"updated_at" TEXT,
@@ -1321,9 +1321,39 @@ CREATE TABLE IF NOT EXISTS "users" (
"email_change_code_expires_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_clusters" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_servers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"cluster_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"private_key_id" INTEGER,
"name" TEXT NOT NULL,
"host" TEXT NOT NULL,
"ssh_user" TEXT NOT NULL,
"ssh_port" INTEGER DEFAULT '22' NOT NULL,
"status" TEXT DEFAULT 'installed' NOT NULL,
"capabilities" TEXT,
"builder_enabled" INTEGER DEFAULT false NOT NULL,
"builder_capacity" INTEGER DEFAULT '0' NOT NULL,
"last_bootstrapped_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"cluster_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
@@ -1763,3 +1793,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_1
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_04_050157_create_v5_projects_table', 315);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130650_create_v5_servers_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_create_v5_clusters_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_131229_add_cluster_id_to_v5_servers_table', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_16_132000_make_v5_server_private_key_nullable', 319);
+233 -16
View File
@@ -1,9 +1,33 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
export default function Home({ status, currentTeam, teams, flux, clusters, cooldServers, privateKeys }) {
const firstPrivateKey = privateKeys[0]?.uuid || '';
const [coolify, setCoolify] = useState(null);
const [checkingCoolify, setCheckingCoolify] = useState(false);
const [bootstrapResult, setBootstrapResult] = useState(null);
const [bootstrapping, setBootstrapping] = useState(false);
const [bootstrapForm, setBootstrapForm] = useState({
host: '',
ssh_user: 'root',
ssh_port: '22',
private_key_uuid: firstPrivateKey,
wg_listen_port: '',
wg_endpoint: '',
enable_builder: true,
builder_capacity: '2',
});
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
function updateBootstrapForm(field, value) {
setBootstrapForm((current) => ({
...current,
[field]: value,
}));
}
async function checkCoolifyCliVersion() {
setCheckingCoolify(true);
@@ -29,6 +53,49 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
}
}
async function bootstrapCoolifyMesh(event) {
event.preventDefault();
setBootstrapping(true);
setBootstrapResult(null);
const payload = {
host: bootstrapForm.host,
ssh_user: bootstrapForm.ssh_user,
ssh_port: Number(bootstrapForm.ssh_port),
private_key_uuid: bootstrapForm.private_key_uuid,
enable_builder: bootstrapForm.enable_builder,
builder_capacity: bootstrapForm.builder_capacity === '' ? null : Number(bootstrapForm.builder_capacity),
wg_listen_port: bootstrapForm.wg_listen_port === '' ? null : Number(bootstrapForm.wg_listen_port),
wg_endpoint: bootstrapForm.wg_endpoint || null,
};
try {
const response = await fetch('/v5/coolify/bootstrap', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify(payload),
});
const result = await response.json();
setBootstrapResult(result);
} catch (error) {
setBootstrapResult({
successful: false,
label: 'Bootstrap failed',
message: 'Could not start the coolify bootstrap command.',
output: null,
errorOutput: null,
exitCode: null,
});
} finally {
setBootstrapping(false);
}
}
return (
<>
<Head title="V5" />
@@ -55,22 +122,52 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
{flux.socket ? <p>Socket: {flux.socket}</p> : null}
</section>
<section aria-labelledby="coold-host-heading">
<h2 id="coold-host-heading">coold host</h2>
<section aria-labelledby="clusters-heading">
<h2 id="clusters-heading">Clusters</h2>
<ul>
{cooldHosts.map((host) => (
<li key={host.id}>
<strong>{host.id}</strong>
{host.wireguardIp ? ` (${host.wireguardIp})` : ''}:
{' '}
{host.capabilities.join(', ')}; builder{' '}
{host.builderEnabled
? `enabled, capacity ${host.builderCapacity}`
: 'disabled'}
</li>
))}
</ul>
{clusters.length === 0 ? (
<p>No clusters have been added yet.</p>
) : (
<ul>
{clusters.map((cluster) => (
<li key={cluster.id}>
<strong>{cluster.name}</strong> {cluster.serversCount}{' '}
{cluster.serversCount === 1 ? 'server' : 'servers'}
{cluster.description ? <p>{cluster.description}</p> : null}
{cluster.servers.length > 0 ? (
<ul>
{cluster.servers.map((server) => (
<li key={server.id}>
{server.name} ({server.status}) {server.host};{' '}
{server.capabilities.join(', ')}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="coold-server-heading">
<h2 id="coold-server-heading">coold servers</h2>
{cooldServers.length === 0 ? (
<p>No coold serverss have been added yet.</p>
) : (
<ul>
{cooldServers.map((host) => (
<li key={host.id}>
<strong>{host.host}</strong> ({host.status}) SSH {host.sshUser}@{host.host}:{host.sshPort};{' '}
{host.capabilities.join(', ')}; builder{' '}
{host.builderEnabled
? `enabled, capacity ${host.builderCapacity}`
: 'disabled'}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="coolify-heading">
@@ -90,6 +187,126 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
{coolify.binary ? <p>Binary: {coolify.binary}</p> : null}
</div>
) : null}
<form onSubmit={bootstrapCoolifyMesh}>
<h3>Bootstrap server</h3>
<label>
Host/IP
<input
type="text"
value={bootstrapForm.host}
onChange={(event) => updateBootstrapForm('host', event.target.value)}
placeholder="203.0.113.10"
required
/>
</label>
<label>
SSH user
<input
type="text"
value={bootstrapForm.ssh_user}
onChange={(event) => updateBootstrapForm('ssh_user', event.target.value)}
required
/>
</label>
<label>
SSH port
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.ssh_port}
onChange={(event) => updateBootstrapForm('ssh_port', event.target.value)}
required
/>
</label>
<label>
Private key
<select
value={bootstrapForm.private_key_uuid}
onChange={(event) => updateBootstrapForm('private_key_uuid', event.target.value)}
required
>
<option value="" disabled>Select a private key</option>
{privateKeys.map((privateKey) => (
<option key={privateKey.uuid} value={privateKey.uuid}>
{privateKey.name}
</option>
))}
</select>
</label>
<details>
<summary>Advanced mesh options</summary>
<label>
WireGuard listen port override
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.wg_listen_port}
onChange={(event) => updateBootstrapForm('wg_listen_port', event.target.value)}
/>
</label>
<label>
WireGuard endpoint override
<input
type="text"
value={bootstrapForm.wg_endpoint}
onChange={(event) => updateBootstrapForm('wg_endpoint', event.target.value)}
placeholder="host.example:51821"
/>
</label>
<label>
<input
type="checkbox"
checked={bootstrapForm.enable_builder}
onChange={(event) => updateBootstrapForm('enable_builder', event.target.checked)}
/>
Enable builder
</label>
<label>
Builder capacity
<input
type="number"
min="0"
max="100"
value={bootstrapForm.builder_capacity}
onChange={(event) => updateBootstrapForm('builder_capacity', event.target.value)}
/>
</label>
</details>
<button type="submit" disabled={bootstrapping || privateKeys.length === 0}>
{bootstrapping ? 'Bootstrapping server...' : 'Bootstrap server'}
</button>
{privateKeys.length === 0 ? (
<p>Add a private key before bootstrapping a server.</p>
) : null}
</form>
{bootstrapResult ? (
<div>
<p>
<strong>{bootstrapResult.label}</strong>
</p>
<p>{bootstrapResult.message}</p>
{bootstrapResult.exitCode !== null ? (
<p>Exit code: {bootstrapResult.exitCode}</p>
) : null}
{bootstrapResult.output ? <pre>{bootstrapResult.output}</pre> : null}
{bootstrapResult.errorOutput ? <pre>{bootstrapResult.errorOutput}</pre> : null}
</div>
) : null}
</section>
<h2>Current team</h2>
+1
View File
@@ -6,4 +6,5 @@ use Illuminate\Support\Facades\Route;
Route::middleware('v5.authenticated')->group(function () {
Route::get('/', HomeController::class)->name('home');
Route::get('/coolify/version', [HomeController::class, 'coolifyCliVersion'])->name('coolify.version');
Route::post('/coolify/bootstrap', [HomeController::class, 'bootstrapCoolify'])->name('coolify.bootstrap');
});
+142 -62
View File
@@ -279,6 +279,24 @@ coolify_bootstrap() {
--yes
}
coolify_bootstrap_with_retry() {
local attempt
local attempts=5
for attempt in $(seq 1 "$attempts"); do
if coolify_bootstrap; then
return
fi
if [ "$attempt" = "$attempts" ]; then
return 1
fi
echo "WARN: coolify bootstrap failed on attempt ${attempt}/${attempts}; retrying because fresh Lima hosts can finish setup after partial bootstrap phases..." >&2
sleep 3
done
}
coolify_dev() {
local command="${1:-help}"
if [ $# -gt 0 ]; then
@@ -398,6 +416,32 @@ follow_logs() {
spin logs -f
}
sync_v5_dev_lima_servers() {
local count
local builder_capacity
local args=()
local instance
local node
count="$(coold_vm_count)"
builder_capacity="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)"
for index in $(seq 1 "$count"); do
instance="$(coold_vm_instance "$index")"
node="$(lima_ssh_target "$index")"
args+=(--server "${instance}|${node}|$(coolify_ssh_user)|22")
done
echo "==> Running pending migrations before syncing v5 dev Lima state..."
spin exec -T coolify php artisan migrate --force
echo "==> Syncing dev Lima VM(s) into v5 clusters/servers..."
spin exec -T coolify php artisan v5:sync-dev-lima-servers \
--cluster="Development-Lima" \
--builder-capacity="$builder_capacity" \
"${args[@]}"
}
configure_flux_dev_for_vm() {
local index="$1"
local host_id
@@ -429,10 +473,25 @@ up() {
local coold_vm_enabled
local follow_dev_logs
local count
local naked=false
local spin_args=()
coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)"
follow_dev_logs="$(read_coolify_env COOLIFY_DEV_FOLLOW_LOGS true)"
count="$(coold_vm_count)"
while [ $# -gt 0 ]; do
case "$1" in
--naked)
naked=true
shift
;;
*)
spin_args+=("$1")
shift
;;
esac
done
if [ "$coold_vm_enabled" != "false" ]; then
echo "==> Starting ${count} Coolify coold VM(s) before Spin..."
for index in $(seq 1 "$count"); do
@@ -443,15 +502,26 @@ up() {
fi
echo "==> Starting Coolify Docker stack with Spin..."
spin up -d "$@"
if [ "${#spin_args[@]}" -gt 0 ]; then
spin up -d "${spin_args[@]}"
else
spin up -d
fi
if [ "$naked" = "true" ]; then
echo "==> --naked enabled. Skipping coolify bootstrap and Flux VM wiring. Use /v5 to bootstrap hosts from the UI."
return
fi
if [ "$coold_vm_enabled" != "false" ]; then
echo "==> Bootstrapping coold VM mesh with coolify..."
coolify_bootstrap
coolify_bootstrap_with_retry
for index in $(seq 1 "$count"); do
configure_flux_dev_for_vm "$index"
done
sync_v5_dev_lima_servers
fi
if [ "$follow_dev_logs" = "false" ]; then
@@ -465,9 +535,24 @@ up() {
down() {
local coold_vm_enabled
local stop_coold_vm
local cleanup=false
local spin_args=()
coold_vm_enabled="$(read_coolify_env COOLIFY_COOLD_VM_ENABLED true)"
stop_coold_vm="$(read_coolify_env COOLIFY_COOLD_VM_STOP_ON_DOWN false)"
while [ $# -gt 0 ]; do
case "$1" in
--cleanup)
cleanup=true
shift
;;
*)
spin_args+=("$1")
shift
;;
esac
done
if [ "$coold_vm_enabled" != "false" ]; then
for index in $(seq 1 "$(coold_vm_count)"); do
echo "==> Stopping coold VM agent service on $(coold_vm_instance "$index")..."
@@ -476,7 +561,16 @@ down() {
fi
echo "==> Stopping Coolify Docker stack with Spin..."
spin down "$@"
if [ "${#spin_args[@]}" -gt 0 ]; then
spin down "${spin_args[@]}"
else
spin down
fi
if [ "$cleanup" = "true" ]; then
clean_vms
return
fi
if [ "$stop_coold_vm" = "true" ]; then
echo "==> Stopping Coolify coold VM..."
@@ -765,11 +859,11 @@ firewall_help() {
Usage: scripts/dev.sh firewall <command>
Commands:
allow <src> <dst> [proto] [port] Allow traffic on every coold VM (proto/port optional)
allow <src> <dst> [proto] [port] Allow traffic through the coolify CLI (proto/port optional)
revoke [id|src] [dst] [proto] [port]
Remove an allow rule from every coold VM
list List allow rules on every coold VM
reconcile Re-apply firewall snapshot on every coold VM
Remove an allow rule through the coolify CLI
list List allow rules through the coolify CLI
containers List registered containers through the coolify CLI
Examples:
scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80
@@ -780,29 +874,21 @@ Examples:
USAGE
}
firewall_api_for_each_vm() {
local label="$1"
local method="$2"
local path="$3"
local body="${4:-}"
local count
count="$(coold_vm_count)"
coolify_firewall() {
local command="$1"
shift
local nodes
local ssh_config
for index in $(seq 1 "$count"); do
instance="$(coold_vm_instance "$index")"
api_ip="$(coold_vm_wg_ip "$index")"
echo "--- ${instance}: ${label} ---"
COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell <<SH
set -e
token="\$(sudo cat /etc/coolify/api-token)"
curl_args=(-fsS --max-time 10 -X "${method}" "http://${api_ip}:8443${path}" -H "Authorization: Bearer \${token}")
if [ -n '${body}' ]; then
curl_args+=(-H 'Content-Type: application/json' -d '${body}')
fi
curl "\${curl_args[@]}"
echo
SH
done
ensure_coolify
nodes="$(coolify_nodes_arg)" || return 1
ssh_config="$(lima_ssh_config)" || return 1
"$(coolify_cli_bin)" firewall "$command" \
--nodes "$nodes" \
--ssh-config "$ssh_config" \
--ssh-user "$(coolify_ssh_user)" \
"$@"
}
firewall_allow() {
@@ -810,7 +896,7 @@ firewall_allow() {
local dst="${2:-}"
local proto="${3:-}"
local port="${4:-}"
local body
local args=()
if [ -z "$src" ] || [ -z "$dst" ]; then
firewall_help >&2
@@ -822,31 +908,15 @@ firewall_allow() {
exit 1
fi
body="$(printf '{"namespace":"default","src":"%s","dst":"%s"' "$src" "$dst")"
args+=(--from "$src" --to "$dst")
if [ -n "$proto" ]; then
body="${body}$(printf ',"proto":"%s"' "$proto")"
args+=(--proto "$proto")
fi
if [ -n "$port" ]; then
body="${body}$(printf ',"port":%s' "$port")"
fi
body="${body}}"
firewall_api_for_each_vm "allow ${src} -> ${dst}" POST /api/v1/firewall/allow "$body"
}
firewall_rule_id() {
local src="$1"
local dst="$2"
local proto="${3:-}"
local port="${4:-0}"
if [ -z "$proto" ]; then
port=0
args+=(--port "$port")
fi
printf 'default|%s|%s|%s|%s' "$src" "$dst" "$proto" "$port" \
| shasum -a 256 \
| awk '{print substr($1, 1, 12)}'
coolify_firewall allow "${args[@]}"
}
firewall_revoke() {
@@ -854,7 +924,7 @@ firewall_revoke() {
local dst="${2:-}"
local proto="${3:-}"
local port="${4:-}"
local id
local args=()
if [ -z "$id_or_src" ]; then
echo "Current firewall allow rule IDs:"
@@ -865,20 +935,26 @@ firewall_revoke() {
fi
if [ -z "$dst" ]; then
id="$id_or_src"
args+=(--id "$id_or_src")
else
id="$(firewall_rule_id "$id_or_src" "$dst" "$proto" "$port")"
args+=(--from "$id_or_src" --to "$dst")
if [ -n "$proto" ]; then
args+=(--proto "$proto")
fi
if [ -n "$port" ]; then
args+=(--port "$port")
fi
fi
firewall_api_for_each_vm "revoke ${id}" DELETE "/api/v1/firewall/allow/${id}"
coolify_firewall revoke "${args[@]}"
}
firewall_list() {
firewall_api_for_each_vm list GET '/api/v1/firewall/allow?namespace=default'
coolify_firewall list "$@"
}
firewall_reconcile() {
firewall_api_for_each_vm reconcile POST /api/v1/firewall/reconcile
firewall_containers() {
coolify_firewall containers "$@"
}
firewall() {
@@ -895,10 +971,10 @@ firewall() {
firewall_revoke "$@"
;;
list)
firewall_list
firewall_list "$@"
;;
reconcile)
firewall_reconcile
containers)
firewall_containers "$@"
;;
-h|--help|help)
firewall_help
@@ -917,10 +993,14 @@ Usage: scripts/dev.sh <command> [spin args]
Commands:
up Start the coold VM, Spin stack, and dev coold agent
up --naked
Start the coold VM(s) and Spin stack only; skip host bootstrap so /v5 can bootstrap
down Stop the dev coold agent and Spin stack
down --cleanup
Stop the dev stack, then delete the coold Lima VM(s) and VM-local state
shell [n] Open a shell inside coold VM n (default: 1)
list Show Lima instances
clean-vms Delete the coold Lima VMs and all VM-local runtime state
clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)
corrosion <command> Inspect Corrosion state, config, logs, and registered containers
firewall <command> Manage dev coold firewall allow rules
example-nginx <command> Start/check example nginx containers with coold DNS
@@ -947,7 +1027,7 @@ case "$cmd" in
limactl list
;;
clean-vms|clean-vm|reset-vms)
clean_vms
down --cleanup
;;
corrosion)
corrosion "$@"
@@ -0,0 +1,80 @@
<?php
it('delegates dev firewall commands to the coolify CLI instead of calling coold APIs directly', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('coolify_firewall()')
->and($script)->toContain('"$(coolify_cli_bin)" firewall "$command"')
->and($script)->toContain('--nodes "$nodes"')
->and($script)->toContain('--ssh-config "$ssh_config"')
->and($script)->not->toContain('/api/v1/firewall/allow')
->and($script)->not->toContain('firewall_api_for_each_vm');
});
it('installs the coolify CLI in both application container images', function (string $dockerfile) {
$contents = file_get_contents(base_path($dockerfile));
expect($contents)->toContain('ARG COOLIFY_CLI_VERSION=nightly')
->and($contents)->toContain('coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz')
->and($contents)->toContain('install -m 0755 /tmp/coolify /usr/local/bin/coolify');
})->with([
'development image' => 'docker/development/Dockerfile',
'production image' => 'docker/production/Dockerfile',
]);
it('does not require predefined UI node environment variables in the development app container', function () {
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
$config = file_get_contents(base_path('config/coold.php'));
expect($compose)->not->toContain('COOLIFY_CLI_NODES:')
->and($compose)->not->toContain('COOLIFY_CLI_SSH_CONFIG:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_LISTEN_PORT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_WG_ENDPOINT_OVERRIDES:')
->and($compose)->not->toContain('COOLIFY_CLI_TIMEOUT:')
->and($config)->not->toContain('cli_nodes')
->and($config)->not->toContain('COOLIFY_CLI_NODES');
});
it('supports a naked up mode that starts VMs and Spin but skips server bootstrap wiring', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local naked=false')
->and($script)->toContain('--naked')
->and($script)->toContain('if [ "${#spin_args[@]}" -gt 0 ]; then')
->and($script)->toContain('spin up -d "${spin_args[@]}"')
->and($script)->toContain('spin up -d')
->and($script)->toContain('if [ "$naked" = "true" ]; then')
->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring')
->and($script)->toContain('coolify_bootstrap_with_retry')
->and($script)->toContain('configure_flux_dev_for_vm "$index"')
->and($script)->toContain('sync_v5_dev_lima_servers');
});
it('retries dev coolify bootstrap because fresh Lima setup can complete across partial phases', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('coolify_bootstrap_with_retry()')
->and($script)->toContain('local attempts=5')
->and($script)->toContain('if coolify_bootstrap; then')
->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases');
});
it('syncs bootstrapped Lima VMs into v5 development server state', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('sync_v5_dev_lima_servers()')
->and($script)->toContain('v5:sync-dev-lima-servers')
->and($script)->toContain('--cluster="Development-Lima"')
->and($script)->toContain('--server "${instance}|${node}|$(coolify_ssh_user)|22"');
});
it('supports down cleanup as the preferred VM cleanup command', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
expect($script)->toContain('local cleanup=false')
->and($script)->toContain('--cleanup')
->and($script)->toContain('if [ "$cleanup" = "true" ]; then')
->and($script)->toContain('clean_vms')
->and($script)->toContain('down --cleanup')
->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)');
});
+300 -7
View File
@@ -5,9 +5,13 @@ use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\V5\EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxHealth;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Route;
@@ -18,7 +22,10 @@ beforeEach(function () {
Config::set('app.maintenance.store', 'array');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
Schema::dropIfExists('v5_projects');
Schema::dropIfExists('private_keys');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
@@ -63,13 +70,87 @@ it('creates v5 project tables in the shared database', function () {
]))->toBeTrue();
});
it('creates v5 cluster tables and lets each server belong to one cluster', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php');
$clusterMigration->up();
expect(Schema::hasTable('v5_clusters'))->toBeTrue()
->and(Schema::hasColumns('v5_clusters', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'created_at',
'updated_at',
]))->toBeTrue();
Schema::dropIfExists('v5_servers');
$serverMigration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php');
$serverMigration->up();
$serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php');
$serverClusterMigration->up();
expect(Schema::hasColumn('v5_servers', 'cluster_id'))->toBeTrue();
});
it('creates v5 server tables in the shared database', function () {
createSharedUserAndTeamTables();
Schema::dropIfExists('v5_servers');
Schema::dropIfExists('v5_clusters');
$clusterMigration = include database_path('migrations/2026_06_16_130649_create_v5_clusters_table.php');
$clusterMigration->up();
$migration = include database_path('migrations/2026_06_16_130650_create_v5_servers_table.php');
$migration->up();
$serverClusterMigration = include database_path('migrations/2026_06_16_131229_add_cluster_id_to_v5_servers_table.php');
$serverClusterMigration->up();
expect(Schema::hasTable('v5_servers'))->toBeTrue()
->and(Schema::hasColumns('v5_servers', [
'id',
'team_id',
'cluster_id',
'created_by_user_id',
'private_key_id',
'name',
'host',
'ssh_user',
'ssh_port',
'status',
'capabilities',
'builder_enabled',
'builder_capacity',
'last_bootstrapped_at',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('includes v5 project tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table');
->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"')
->and($schema)->toContain('"cluster_id" INTEGER')
->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"')
->and($schema)->toContain('"private_key_id" INTEGER')
->and($schema)->toContain('2026_06_16_130650_create_v5_servers_table')
->and($schema)->toContain('2026_06_16_130649_create_v5_clusters_table')
->and($schema)->toContain('2026_06_16_131229_add_cluster_id_to_v5_servers_table')
->and($schema)->not->toContain('v5_hosts');
});
it('redirects guests to the shared login', function () {
@@ -106,18 +187,57 @@ it('serves the v5 inertia shell', function () {
->assertSee('v5-ready', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertSee('coold-dev')
->assertSee('coold-dev-2')
->assertSee('100.64.0.1')
->assertSee('100.64.0.2')
->assertSee('builder')
->assertSee('builderCapacity')
->assertSee('"clusters":[]', false)
->assertSee('"cooldServers":[]', false)
->assertDontSee('coold-dev')
->assertDontSee('100.64.0.1')
->assertSee('V5 Shared Team')
->assertSee('Shared team details')
->assertSee('owner')
->assertSee($user->email);
});
it('shows v5 clusters with their servers on the inertia shell', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Lima Key');
$cluster = Cluster::query()->create([
'team_id' => $team->id,
'created_by_user_id' => $user->id,
'name' => 'Development-Lima',
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
V5Server::query()->create([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey->id,
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_user' => 'developer',
'ssh_port' => 22,
'status' => 'installed',
'capabilities' => ['coold', 'builder'],
'builder_enabled' => true,
'builder_capacity' => 2,
'last_bootstrapped_at' => now(),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"clusters":[', false)
->assertSee('"name":"Development-Lima"', false)
->assertSee('"serversCount":1', false)
->assertSee('"name":"coold-dev"', false)
->assertSee('"host":"lima-coold-dev"', false);
});
it('selects a shared team when the session has no current team', function () {
$this->withoutVite();
fakeFluxHealth();
@@ -218,6 +338,127 @@ it('shows when coolify version check fails', function () {
]);
});
it('rejects coolify bootstrap when the selected private key is not owned by the current team', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Other Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$privateKey = createV5PrivateKey($otherTeam, 'Other Key');
Process::fake();
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/coolify/bootstrap', [
'host' => '192.0.2.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'private_key_uuid' => $privateKey->uuid,
])
->assertForbidden()
->assertJson([
'successful' => false,
'label' => 'Private key unavailable',
]);
Process::assertDidntRun(fn () => true);
});
it('runs coolify bootstrap from dynamic UI input and a selected team private key', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Bootstrap Key');
Config::set('coold.coolify_cli_bin', '/usr/local/bin/coolify');
Config::set('coold.dev_builder_capacity', 2);
Process::fake([
'*' => Process::result(output: 'Bootstrapping mesh...', exitCode: 0),
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/coolify/bootstrap', [
'host' => '192.0.2.10',
'ssh_user' => 'ubuntu',
'ssh_port' => 2222,
'private_key_uuid' => $privateKey->uuid,
'wg_listen_port' => 51821,
'wg_endpoint' => 'example.test:51821',
'enable_builder' => true,
'builder_capacity' => 3,
])
->assertSuccessful()
->assertJson([
'successful' => true,
'label' => 'Bootstrap finished',
'message' => 'coolify init bootstrap completed successfully.',
'output' => 'Bootstrapping mesh...',
'exitCode' => 0,
]);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('"host":"192.0.2.10"', false)
->assertSee('"status":"installed"', false)
->assertSee('"capabilities":["coold","builder"]', false);
$sshKeyPath = null;
Process::assertRan(function ($process) use (&$sshKeyPath) {
preg_match("/'--ssh-key' '([^']+)'/", $process->command, $matches);
$sshKeyPath = $matches[1] ?? null;
return $process->timeout === 300
&& str_contains($process->command, "'/usr/local/bin/coolify' 'init' 'bootstrap'")
&& str_contains($process->command, "'--nodes' '192.0.2.10:2222'")
&& str_contains($process->command, "'--ssh-user' 'ubuntu'")
&& str_contains($process->command, "'--wg-listen-port-overrides' '192.0.2.10:2222=51821'")
&& str_contains($process->command, "'--wg-endpoint-overrides' '192.0.2.10:2222=example.test:51821'")
&& str_contains($process->command, "'--coold-version' 'nightly'")
&& str_contains($process->command, "'--corrosion-version' 'v1.0.0'")
&& str_contains($process->command, "'--enable-builder'")
&& str_contains($process->command, "'--builder-capacity' '3'")
&& str_contains($process->command, "'--yes'")
&& ! str_contains($process->command, 'COOLIFY_CLI_NODES');
});
expect($sshKeyPath)->not->toBeNull()
->and(file_exists($sshKeyPath))->toBeFalse();
});
it('syncs dev Lima VMs into v5 clusters and servers', function () {
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
$privateKey = createV5PrivateKey($team, 'Dev Lima Key');
$exitCode = Artisan::call('v5:sync-dev-lima-servers', [
'--team-id' => $team->id,
'--user-id' => $user->id,
'--private-key-id' => $privateKey->id,
'--cluster' => 'Development-Lima',
'--builder-capacity' => 2,
'--server' => [
'coold-dev|lima-coold-dev|developer|22',
'coold-dev-2|lima-coold-dev-2|developer|22',
],
]);
expect($exitCode)->toBe(0)
->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1)
->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'lima-coold-dev')->exists())->toBeTrue()
->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'lima-coold-dev-2')->exists())->toBeTrue();
});
function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void
{
app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) {
@@ -253,6 +494,45 @@ function createSharedUserAndTeamTables(): void
$table->timestamps();
});
Schema::create('private_keys', function ($table) {
$table->id();
$table->string('uuid')->unique();
$table->string('name');
$table->string('description')->nullable();
$table->longText('private_key');
$table->string('fingerprint')->nullable();
$table->boolean('is_git_related')->default(false);
$table->foreignId('team_id');
$table->timestamps();
});
Schema::create('v5_clusters', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('created_by_user_id');
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
Schema::create('v5_servers', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('cluster_id')->nullable();
$table->foreignId('created_by_user_id');
$table->foreignId('private_key_id')->nullable();
$table->string('name');
$table->string('host');
$table->string('ssh_user');
$table->unsignedInteger('ssh_port');
$table->string('status')->default('installed');
$table->json('capabilities')->nullable();
$table->boolean('builder_enabled')->default(false);
$table->unsignedInteger('builder_capacity')->default(0);
$table->timestamp('last_bootstrapped_at')->nullable();
$table->timestamps();
});
Schema::create('team_user', function ($table) {
$table->id();
$table->foreignId('team_id');
@@ -264,6 +544,19 @@ function createSharedUserAndTeamTables(): void
});
}
function createV5PrivateKey(Team $team, string $name): PrivateKey
{
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
'uuid' => str($name)->slug().'-uuid',
'name' => $name,
'description' => null,
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
'fingerprint' => str($name)->slug()->toString(),
'is_git_related' => false,
'team_id' => $team->id,
]));
}
/**
* @return array{0: User, 1: Team}
*/