mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 08:15:00 +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:
1 parent
586588196d
commit
1e038fecae
16 files changed
+1389
-108
No files matched your search
@@ -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) : '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user