mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-23 20:25:39 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)');
|
||||
});
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user