feat(v5): add WireGuard and coold/corrosion config to clusters/servers

Add WireGuard networking fields (interface, management pool, listen port),
container network pool, coold/corrosion versioning, and builder CPU quota
to clusters and servers via new migrations and model fillables.

Expose full cluster and server CRUD on the Clusters page with private key
selection, pending state tracking, and new form primitives (Field, Input,
Textarea). Add server status check fields and a lima test VM config for
development.
This commit is contained in:
Andras Bacsai
2026-06-19 07:23:45 +02:00
parent 5d57b131ff
commit 3ae87334bd
30 changed files with 3876 additions and 103 deletions
+32
View File
@@ -0,0 +1,32 @@
vmType: "vz"
arch: "default"
cpus: 2
memory: "2GiB"
disk: "20GiB"
containerd:
system: false
user: false
ssh:
localPort: 60003
images:
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
arch: "x86_64"
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
arch: "aarch64"
mounts: []
provision:
- mode: system
script: |
#!/usr/bin/env bash
set -euxo pipefail
export DEBIAN_FRONTEND=noninteractive
install -d -m 700 /root/.ssh
cat >/root/.ssh/authorized_keys <<'KEYS'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
KEYS
chmod 600 /root/.ssh/authorized_keys
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
systemctl restart ssh || systemctl restart sshd
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl openssh-server sudo
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
+11 -5
View File
@@ -33,7 +33,13 @@ class V5SyncDevLimaServers extends Command
$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;
$privateKey = is_numeric($privateKeyId)
? PrivateKey::query()->find((int) $privateKeyId)
: PrivateKey::query()
->where('team_id', $team?->id)
->where('is_git_related', false)
->orderBy('id')
->first();
if (! $team instanceof Team || ! $user instanceof User) {
$this->warn('Cannot sync dev Lima servers without an existing team and user.');
@@ -74,14 +80,14 @@ class V5SyncDevLimaServers extends Command
Server::query()->updateOrCreate([
'team_id' => $team->id,
'host' => $host,
'ssh_port' => (int) $sshPort,
], [
'cluster_id' => $cluster->id,
'name' => $name,
], [
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey?->id,
'name' => $name,
'host' => $host,
'ssh_user' => $sshUser,
'ssh_port' => (int) $sshPort,
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
+715 -4
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\V5;
use App\Http\Controllers\Controller;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\V5\Cluster as V5Cluster;
@@ -12,6 +13,8 @@ use App\Services\Flux\FluxHealth;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -45,6 +48,7 @@ class DashboardController extends Controller
return Inertia::render('Clusters', [
'flux' => $fluxHealth->check(),
'clusters' => $this->clusters($currentTeam),
'privateKeys' => $this->privateKeys($currentTeam),
'projects' => $projects,
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
@@ -98,9 +102,28 @@ class DashboardController extends Controller
Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id),
],
'description' => ['nullable', 'string', 'max:1000'],
'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'],
'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'],
'namespaces' => ['sometimes', 'array', 'min:1'],
'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'],
'default_deny_containers' => ['sometimes', 'boolean'],
'coold_version' => ['sometimes', 'string', 'max:64'],
'corrosion_version' => ['sometimes', 'string', 'max:64'],
'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
'builder_enabled' => ['sometimes', 'boolean'],
'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'],
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
'builder_memory_max' => ['sometimes', 'string', 'max:32'],
'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'],
]);
$cluster = V5Cluster::query()->create([
...$this->defaultClusterConfiguration(),
...collect($validated)->except(['name', 'description'])->all(),
'team_id' => $currentTeam->id,
'created_by_user_id' => $request->user()->id,
'name' => $validated['name'],
@@ -117,8 +140,587 @@ class DashboardController extends Controller
], 201);
}
public function bootstrapServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (
! $currentTeam instanceof Team
|| $cluster->team_id !== $currentTeam->id
|| $server->team_id !== $currentTeam->id
|| $server->cluster_id !== $cluster->id
) {
abort(404);
}
if ($server->last_bootstrapped_at !== null) {
return response()->json([
'message' => 'This server is already bootstrapped.',
], 409);
}
$installedServers = $cluster->servers()
->with('privateKey')
->whereNotNull('last_bootstrapped_at')
->orderBy('name')
->get();
$action = $installedServers->isEmpty() ? 'bootstrap' : 'extend';
$server->load('privateKey');
$servers = $installedServers->toBase()
->push($server)
->unique('id')
->values();
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
return response()->json([
'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.',
], 422);
}
$cluster->update([
'last_cli_action' => $action,
'last_cli_status' => 'running',
'last_cli_summary' => "Starting Coolify CLI {$action} for {$server->name}...",
'last_cli_ran_at' => now(),
]);
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16);
if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) {
$cluster->update([
'last_cli_status' => 'failed',
'last_cli_summary' => 'Could not create a temporary SSH configuration directory.',
'last_cli_ran_at' => now(),
]);
return response()->json([
'cluster' => $this->freshSerializedCluster($cluster),
], 500);
}
try {
$sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory);
$result = Process::timeout(max(60, (int) $cluster->builder_timeout_secs + 120))
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
$output = trim($result->output()."\n".$result->errorOutput());
$successful = $result->successful();
} catch (\Throwable $e) {
$output = $e->getMessage();
$successful = false;
} finally {
$this->deleteDirectory($tempDirectory);
}
$cluster->update([
'last_cli_action' => $action,
'last_cli_status' => $successful ? 'succeeded' : 'failed',
'last_cli_summary' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(),
'last_cli_ran_at' => now(),
]);
if ($successful) {
$capabilities = collect($server->capabilities ?? [])
->push('coold')
->when($server->builder_enabled, fn ($capabilities) => $capabilities->push('builder'))
->unique()
->values()
->all();
$server->update([
'status' => 'installed',
'capabilities' => $capabilities,
'last_bootstrapped_at' => now(),
]);
}
$payload = [
'cluster' => $this->freshSerializedCluster($cluster),
];
if (! $successful) {
$payload['message'] = $cluster->last_cli_summary;
}
return response()->json($payload, $successful ? 200 : 500);
}
public function storeServer(Request $request, V5Cluster $cluster): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) {
abort(403);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'host' => [
'required',
'string',
'max:255',
Rule::unique('v5_servers', 'host')
->where('team_id', $currentTeam->id)
->where('ssh_port', (int) $request->input('ssh_port', 22)),
],
'ssh_user' => ['required', 'string', 'max:255'],
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
'private_key_id' => [
'required',
'integer',
Rule::exists('private_keys', 'id')->where('team_id', $currentTeam->id),
],
'node_address' => ['nullable', 'string', 'max:255'],
'builder_enabled' => ['sometimes', 'boolean'],
'builder_capacity' => ['sometimes', 'integer', 'min:0', 'max:1000'],
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'],
]);
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
$builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity);
$builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota;
$devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']);
V5Server::query()->create([
'team_id' => $currentTeam->id,
'cluster_id' => $cluster->id,
'created_by_user_id' => $request->user()->id,
'name' => $validated['name'],
'host' => $validated['host'],
'ssh_user' => $validated['ssh_user'],
'ssh_port' => $validated['ssh_port'],
'private_key_id' => $validated['private_key_id'] ?? null,
'status' => 'pending',
'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'],
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderEnabled ? $builderCapacity : 0,
'builder_cpu_quota' => $builderCpuQuota,
'node_address' => $validated['node_address'] ?? $validated['host'],
'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'],
'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'],
]);
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return response()->json([
'cluster' => $this->serializeCluster($cluster),
], 201);
}
public function updateServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (
! $currentTeam instanceof Team
|| $cluster->team_id !== $currentTeam->id
|| $server->team_id !== $currentTeam->id
|| $server->cluster_id !== $cluster->id
) {
abort(404);
}
$validated = $request->validate([
'builder_enabled' => ['required', 'boolean'],
'builder_capacity' => ['required', 'integer', 'min:0', 'max:1000'],
'builder_cpu_quota' => ['required', 'string', 'max:32'],
]);
$builderEnabled = (bool) $validated['builder_enabled'];
$capabilities = collect($server->capabilities ?? [])
->push('coold')
->when($builderEnabled, fn ($capabilities) => $capabilities->push('builder'))
->when(! $builderEnabled, fn ($capabilities) => $capabilities->reject(fn (string $capability) => $capability === 'builder'))
->unique()
->values()
->all();
$server->update([
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderEnabled ? (int) $validated['builder_capacity'] : 0,
'builder_cpu_quota' => $validated['builder_cpu_quota'],
]);
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return response()->json([
'cluster' => $this->serializeCluster($cluster),
]);
}
public function checkServer(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (
! $currentTeam instanceof Team
|| $cluster->team_id !== $currentTeam->id
|| $server->team_id !== $currentTeam->id
|| $server->cluster_id !== $cluster->id
) {
abort(404);
}
if (! $server->privateKey instanceof PrivateKey) {
$server->update([
'last_status_check' => 'failed',
'last_status_output' => 'No private key is attached to this server.',
'last_status_checked_at' => now(),
]);
return response()->json([
'cluster' => $this->freshSerializedCluster($cluster),
]);
}
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
if ($keyLocation === false) {
$server->update([
'last_status_check' => 'failed',
'last_status_output' => 'Could not create a temporary SSH key file.',
'last_status_checked_at' => now(),
]);
return response()->json([
'cluster' => $this->freshSerializedCluster($cluster),
]);
}
file_put_contents($keyLocation, $server->privateKey->private_key);
chmod($keyLocation, 0600);
$target = "{$server->ssh_user}@{$server->host}";
$command = [
'ssh',
'-o',
'BatchMode=yes',
'-o',
'LogLevel=ERROR',
'-o',
'StrictHostKeyChecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
'-o',
'ConnectTimeout=10',
'-o',
'IdentitiesOnly=yes',
'-i',
$keyLocation,
'-p',
(string) $server->ssh_port,
$target,
"printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true",
];
try {
$result = Process::timeout(15)->run($command);
$output = trim($result->output()."\n".$result->errorOutput());
$status = $result->successful() ? 'reachable' : 'failed';
} catch (\Throwable $e) {
$output = $e->getMessage();
$status = 'failed';
} finally {
@unlink($keyLocation);
}
$server->update([
'last_status_check' => $status,
'last_status_output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
'last_status_checked_at' => now(),
]);
return response()->json([
'cluster' => $this->freshSerializedCluster($cluster),
]);
}
public function destroyServer(Request $request, V5Cluster $cluster, V5Server $server): \Illuminate\Http\Response|JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (
! $currentTeam instanceof Team
|| $cluster->team_id !== $currentTeam->id
|| $server->team_id !== $currentTeam->id
|| $server->cluster_id !== $cluster->id
) {
abort(404);
}
if ($server->last_bootstrapped_at !== null) {
return response()->json([
'message' => 'Only unbootstrapped servers can be deleted.',
], 409);
}
$server->delete();
return response()->json([
'cluster' => $this->freshSerializedCluster($cluster),
]);
}
public function destroyCluster(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $cluster->team_id !== $currentTeam->id) {
abort(404);
}
if ($cluster->servers()->exists()) {
return response()->json([
'message' => 'Only empty clusters can be deleted.',
], 422);
}
$cluster->delete();
return response()->noContent();
}
/**
* @return array<int, array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>}>
* @param Collection<int, V5Server> $servers
* @return array<int, string>
*/
/**
* @return array{listen_port: int|null, endpoint: string|null}
*/
private function devLimaWireguardOverrides(string $host, int $sshPort): array
{
if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') {
return ['listen_port' => null, 'endpoint' => null];
}
if ($sshPort < 60001 || $sshPort > 60009) {
return ['listen_port' => null, 'endpoint' => null];
}
$wireguardPort = $sshPort - 8180;
return [
'listen_port' => $wireguardPort,
'endpoint' => "host.lima.internal:{$wireguardPort}",
];
}
private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array
{
$command = [
$this->coolifyCliBin(),
'init',
$action,
'--format',
'table',
'--nodes',
$servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','),
'--ssh-config',
$sshConfigLocation,
'--namespaces',
implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES),
'--container-pool',
$cluster->container_network_pool,
'--container-prefix',
(string) $cluster->container_network_prefix,
'--wg-mgmt-pool',
$cluster->wireguard_management_pool,
'--wg-interface',
$cluster->wireguard_interface,
'--wg-listen-port',
(string) $cluster->wireguard_listen_port,
'--coold-version',
$cluster->coold_version,
'--corrosion-version',
$cluster->corrosion_version,
'--corrosion-gossip-port',
(string) $cluster->corrosion_gossip_port,
'--corrosion-api-port',
(string) $cluster->corrosion_api_port,
];
if ($action === 'extend') {
array_push($command, '--new-nodes', $this->bootstrapNode($newServer));
}
$listenOverrides = $this->wireguardListenPortOverrides($servers);
if ($listenOverrides !== '') {
array_push($command, '--wg-listen-port-overrides', $listenOverrides);
}
$endpointOverrides = $this->wireguardEndpointOverrides($servers);
if ($endpointOverrides !== '') {
array_push($command, '--wg-endpoint-overrides', $endpointOverrides);
}
if (! $cluster->default_deny_containers) {
$command[] = '--skip-default-deny';
}
$builderServers = $servers->filter(fn (V5Server $server) => $server->builder_enabled);
if ($cluster->builder_enabled && $builderServers->isNotEmpty()) {
array_push(
$command,
'--enable-builder',
'--builder-hosts',
$builderServers
->map(fn (V5Server $server) => $this->bootstrapNode($server))
->implode(','),
'--builder-capacity',
(string) $cluster->builder_capacity,
'--builder-cpu-quota',
$cluster->builder_cpu_quota,
'--builder-memory-max',
$cluster->builder_memory_max,
'--builder-timeout-secs',
(string) $cluster->builder_timeout_secs,
);
}
$command[] = '--yes';
return $command;
}
private function coolifyCliBin(): string
{
$configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify');
$devBinary = base_path('.dev/bin/coolify');
if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) {
return $devBinary;
}
return $configuredBinary;
}
private function isRunnableDevelopmentCliBinary(string $binary): bool
{
if (! is_file($binary)) {
return false;
}
$header = file_get_contents($binary, false, null, 0, 4);
if ($header === false) {
return false;
}
if (str_starts_with($header, '#!')) {
return true;
}
if ($header === "\x7FELF") {
return true;
}
return false;
}
private function bootstrapNode(V5Server $server): string
{
return "v5-server-{$server->id}";
}
/**
* @param Collection<int, V5Server> $servers
*/
private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string
{
$config = '';
$servers->each(function (V5Server $server) use (&$config, $tempDirectory): void {
$keyLocation = "{$tempDirectory}/server-{$server->id}.key";
file_put_contents($keyLocation, $server->privateKey->private_key);
chmod($keyLocation, 0600);
$config .= implode("\n", [
'Host '.$this->bootstrapNode($server),
' HostName '.$server->host,
' Port '.$server->ssh_port,
' User '.$server->ssh_user,
' IdentityFile '.$keyLocation,
' IdentitiesOnly yes',
' LogLevel ERROR',
' StrictHostKeyChecking no',
' UserKnownHostsFile /dev/null',
' BatchMode yes',
'',
]);
});
$sshConfigLocation = "{$tempDirectory}/ssh.config";
file_put_contents($sshConfigLocation, $config);
chmod($sshConfigLocation, 0600);
return $sshConfigLocation;
}
private function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
foreach (scandir($directory) ?: [] as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$path = "{$directory}/{$file}";
if (is_dir($path)) {
$this->deleteDirectory($path);
continue;
}
@unlink($path);
}
@rmdir($directory);
}
/**
* @param Collection<int, V5Server> $servers
*/
private function wireguardListenPortOverrides(Collection $servers): string
{
return $servers
->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null)
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override)
->implode(',');
}
/**
* @param Collection<int, V5Server> $servers
*/
private function wireguardEndpointOverrides(Collection $servers): string
{
return $servers
->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null)
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override)
->implode(',');
}
/**
* @return array<int, array<string, mixed>>
*/
private function clusters(mixed $currentTeam): array
{
@@ -139,7 +741,28 @@ class DashboardController extends Controller
}
/**
* @return array{id: string, name: string, description: string|null, serversCount: int, servers: array<int, array{id: string, name: string, host: string, sshUser: string, sshPort: int, status: string, capabilities: array<int, string>, builderEnabled: bool, builderCapacity: int, privateKeyName: string|null, lastBootstrappedAt: string|null}>}
* @return array<int, array{id: string, name: string}>
*/
private function privateKeys(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return PrivateKey::query()
->where('team_id', $currentTeam->id)
->where('is_git_related', false)
->orderBy('name')
->get(['id', 'name'])
->map(fn (PrivateKey $privateKey) => [
'id' => (string) $privateKey->id,
'name' => $privateKey->name,
])
->all();
}
/**
* @return array<string, mixed>
*/
private function serializeCluster(V5Cluster $cluster): array
{
@@ -147,23 +770,111 @@ class DashboardController extends Controller
'id' => (string) $cluster->id,
'name' => $cluster->name,
'description' => $cluster->description,
'wireguardInterface' => $cluster->wireguard_interface,
'wireguardManagementPool' => $cluster->wireguard_management_pool,
'wireguardListenPort' => $cluster->wireguard_listen_port,
'containerNetworkPool' => $cluster->container_network_pool,
'containerNetworkPrefix' => $cluster->container_network_prefix,
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
'defaultDenyContainers' => $cluster->default_deny_containers,
'cooldVersion' => $cluster->coold_version,
'corrosionVersion' => $cluster->corrosion_version,
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
'corrosionApiPort' => $cluster->corrosion_api_port,
'builderEnabled' => $cluster->builder_enabled,
'builderCapacity' => $cluster->builder_capacity,
'builderCpuQuota' => $cluster->builder_cpu_quota,
'builderMemoryMax' => $cluster->builder_memory_max,
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
'lastCliAction' => $cluster->last_cli_action,
'lastCliStatus' => $cluster->last_cli_status,
'lastCliSummary' => $cluster->last_cli_summary,
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
'servers' => $cluster->servers->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'name' => $server->name,
'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,
'builderCpuQuota' => $server->builder_cpu_quota,
'nodeAddress' => $server->node_address,
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
'wireguardManagementIp' => $server->wireguard_management_ip,
'wireguardPublicKey' => $server->wireguard_public_key,
'containerSubnets' => $server->container_subnets ?? [],
'privateKeyName' => $server->privateKey?->name,
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
'lastStatusCheck' => $server->last_status_check,
'lastStatusOutput' => $server->last_status_output,
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
])->all(),
];
}
/**
* @return array<string, mixed>
*/
private function freshSerializedCluster(V5Cluster $cluster): array
{
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
$cluster->loadCount('servers');
return $this->serializeCluster($cluster);
}
/**
* @return array<string, mixed>
*/
private function defaultClusterConfiguration(): array
{
return [
'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE,
'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT,
'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL,
'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX,
'namespaces' => V5Cluster::DEFAULT_NAMESPACES,
'default_deny_containers' => true,
'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION,
'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION,
'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT,
'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT,
'builder_enabled' => true,
'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY,
'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA,
'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX,
'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS,
];
}
private function ipv4CidrRule(): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value) || ! str_contains($value, '/')) {
$fail('The :attribute must be a valid IPv4 CIDR range.');
return;
}
[$ip, $prefix] = explode('/', $value, 2);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false
|| ! ctype_digit($prefix)
|| (int) $prefix < 0
|| (int) $prefix > 32
) {
$fail('The :attribute must be a valid IPv4 CIDR range.');
}
};
}
/**
* @param array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}> $projects
* @return array{0: array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}|null, 1: array{uuid: string, name: string}|null}
+76
View File
@@ -11,13 +11,89 @@ class Cluster extends V5Model
{
protected $table = 'v5_clusters';
public const DEFAULT_WIREGUARD_INTERFACE = 'wg0';
public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16';
public const DEFAULT_WIREGUARD_LISTEN_PORT = 51820;
public const DEFAULT_CONTAINER_NETWORK_POOL = '10.210.0.0/16';
public const DEFAULT_CONTAINER_NETWORK_PREFIX = 24;
public const DEFAULT_NAMESPACES = ['default'];
public const DEFAULT_COOLD_VERSION = 'nightly';
public const DEFAULT_CORROSION_VERSION = 'v1.0.0';
public const DEFAULT_CORROSION_GOSSIP_PORT = 8787;
public const DEFAULT_CORROSION_API_PORT = 8080;
public const DEFAULT_BUILDER_CAPACITY = 2;
public const DEFAULT_BUILDER_CPU_QUOTA = '200%';
public const DEFAULT_BUILDER_MEMORY_MAX = '2G';
public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800;
protected $fillable = [
'team_id',
'created_by_user_id',
'name',
'description',
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
];
protected $attributes = [
'wireguard_interface' => self::DEFAULT_WIREGUARD_INTERFACE,
'wireguard_management_pool' => self::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
'wireguard_listen_port' => self::DEFAULT_WIREGUARD_LISTEN_PORT,
'container_network_pool' => self::DEFAULT_CONTAINER_NETWORK_POOL,
'container_network_prefix' => self::DEFAULT_CONTAINER_NETWORK_PREFIX,
'default_deny_containers' => true,
'coold_version' => self::DEFAULT_COOLD_VERSION,
'corrosion_version' => self::DEFAULT_CORROSION_VERSION,
'corrosion_gossip_port' => self::DEFAULT_CORROSION_GOSSIP_PORT,
'corrosion_api_port' => self::DEFAULT_CORROSION_API_PORT,
'builder_enabled' => true,
'builder_capacity' => self::DEFAULT_BUILDER_CAPACITY,
'builder_cpu_quota' => self::DEFAULT_BUILDER_CPU_QUOTA,
'builder_memory_max' => self::DEFAULT_BUILDER_MEMORY_MAX,
'builder_timeout_secs' => self::DEFAULT_BUILDER_TIMEOUT_SECS,
];
protected function casts(): array
{
return [
'namespaces' => 'array',
'default_deny_containers' => 'boolean',
'builder_enabled' => 'boolean',
'last_cli_ran_at' => 'datetime',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
+12
View File
@@ -24,7 +24,17 @@ class Server extends V5Model
'capabilities',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'last_bootstrapped_at',
'last_status_check',
'last_status_output',
'last_status_checked_at',
];
protected function casts(): array
@@ -32,7 +42,9 @@ class Server extends V5Model
return [
'capabilities' => 'array',
'builder_enabled' => 'boolean',
'container_subnets' => 'array',
'last_bootstrapped_at' => 'datetime',
'last_status_checked_at' => 'datetime',
];
}
@@ -0,0 +1,90 @@
<?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_clusters', function (Blueprint $table) {
$table->string('wireguard_interface')->default('wg0')->after('description');
$table->string('wireguard_management_pool')->default('100.64.0.0/16')->after('wireguard_interface');
$table->unsignedInteger('wireguard_listen_port')->default(51820)->after('wireguard_management_pool');
$table->string('container_network_pool')->default('10.210.0.0/16')->after('wireguard_listen_port');
$table->unsignedTinyInteger('container_network_prefix')->default(24)->after('container_network_pool');
$table->json('namespaces')->nullable()->after('container_network_prefix');
$table->boolean('default_deny_containers')->default(true)->after('namespaces');
$table->string('coold_version')->default('nightly')->after('default_deny_containers');
$table->string('corrosion_version')->default('v1.0.0')->after('coold_version');
$table->unsignedInteger('corrosion_gossip_port')->default(8787)->after('corrosion_version');
$table->unsignedInteger('corrosion_api_port')->default(8080)->after('corrosion_gossip_port');
$table->boolean('builder_enabled')->default(true)->after('corrosion_api_port');
$table->unsignedInteger('builder_capacity')->default(2)->after('builder_enabled');
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
$table->string('builder_memory_max')->default('2G')->after('builder_cpu_quota');
$table->unsignedInteger('builder_timeout_secs')->default(1800)->after('builder_memory_max');
$table->string('last_cli_action')->nullable()->after('builder_timeout_secs');
$table->string('last_cli_status')->nullable()->after('last_cli_action');
$table->text('last_cli_summary')->nullable()->after('last_cli_status');
$table->timestamp('last_cli_ran_at')->nullable()->after('last_cli_summary');
});
Schema::table('v5_servers', function (Blueprint $table) {
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
$table->string('node_address')->nullable()->after('builder_cpu_quota');
$table->unsignedInteger('wireguard_listen_port_override')->nullable()->after('node_address');
$table->string('wireguard_endpoint_override')->nullable()->after('wireguard_listen_port_override');
$table->string('wireguard_management_ip')->nullable()->after('wireguard_endpoint_override');
$table->string('wireguard_public_key')->nullable()->after('wireguard_management_ip');
$table->json('container_subnets')->nullable()->after('wireguard_public_key');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn([
'builder_cpu_quota',
'node_address',
'wireguard_listen_port_override',
'wireguard_endpoint_override',
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
]);
});
Schema::table('v5_clusters', function (Blueprint $table) {
$table->dropColumn([
'wireguard_interface',
'wireguard_management_pool',
'wireguard_listen_port',
'container_network_pool',
'container_network_prefix',
'namespaces',
'default_deny_containers',
'coold_version',
'corrosion_version',
'corrosion_gossip_port',
'corrosion_api_port',
'builder_enabled',
'builder_capacity',
'builder_cpu_quota',
'builder_memory_max',
'builder_timeout_secs',
'last_cli_action',
'last_cli_status',
'last_cli_summary',
'last_cli_ran_at',
]);
});
}
};
@@ -0,0 +1,36 @@
<?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
{
if (Schema::hasColumn('v5_servers', 'builder_cpu_quota')) {
return;
}
Schema::table('v5_servers', function (Blueprint $table) {
$table->string('builder_cpu_quota')->default('200%')->after('builder_capacity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('v5_servers', 'builder_cpu_quota')) {
return;
}
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn('builder_cpu_quota');
});
}
};
@@ -0,0 +1,34 @@
<?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->string('last_status_check')->nullable()->after('last_bootstrapped_at');
$table->text('last_status_output')->nullable()->after('last_status_check');
$table->timestamp('last_status_checked_at')->nullable()->after('last_status_output');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn([
'last_status_check',
'last_status_output',
'last_status_checked_at',
]);
});
}
};
+33
View File
@@ -1327,6 +1327,26 @@ CREATE TABLE IF NOT EXISTS "v5_clusters" (
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"wireguard_interface" TEXT DEFAULT 'wg0' NOT NULL,
"wireguard_management_pool" TEXT DEFAULT '100.64.0.0/16' NOT NULL,
"wireguard_listen_port" INTEGER DEFAULT '51820' NOT NULL,
"container_network_pool" TEXT DEFAULT '10.210.0.0/16' NOT NULL,
"container_network_prefix" INTEGER DEFAULT '24' NOT NULL,
"namespaces" JSON,
"default_deny_containers" INTEGER DEFAULT true NOT NULL,
"coold_version" TEXT DEFAULT 'nightly' NOT NULL,
"corrosion_version" TEXT DEFAULT 'v1.0.0' NOT NULL,
"corrosion_gossip_port" INTEGER DEFAULT '8787' NOT NULL,
"corrosion_api_port" INTEGER DEFAULT '8080' NOT NULL,
"builder_enabled" INTEGER DEFAULT true NOT NULL,
"builder_capacity" INTEGER DEFAULT '2' NOT NULL,
"builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL,
"builder_memory_max" TEXT DEFAULT '2G' NOT NULL,
"builder_timeout_secs" INTEGER NOT NULL DEFAULT '1800',
"last_cli_action" TEXT,
"last_cli_status" TEXT,
"last_cli_summary" TEXT,
"last_cli_ran_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
@@ -1345,7 +1365,17 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"capabilities" TEXT,
"builder_enabled" INTEGER DEFAULT false NOT NULL,
"builder_capacity" INTEGER DEFAULT '0' NOT NULL,
"builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL,
"node_address" TEXT,
"wireguard_listen_port_override" INTEGER,
"wireguard_endpoint_override" TEXT,
"wireguard_management_ip" TEXT,
"wireguard_public_key" TEXT,
"container_subnets" JSON,
"last_bootstrapped_at" TEXT,
"last_status_check" TEXT,
"last_status_output" TEXT,
"last_status_checked_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
@@ -1783,3 +1813,6 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_0
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 (316, '2026_06_16_130650_v5_create_servers_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130649_v5_create_clusters_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_17_172845_add_status_check_fields_to_v5_servers_table', 320);
+11 -5
View File
@@ -59,26 +59,32 @@ class V5DevLimaSeeder extends Seeder
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'wireguard_listen_port_override' => $server['wireguard_listen_port_override'],
'wireguard_endpoint_override' => $server['wireguard_endpoint_override'],
'last_bootstrapped_at' => now(),
]);
}
}
/**
* @return array<int, array{name: string, host: string, ssh_port: int}>
* @return array<int, array{name: string, host: string, ssh_port: int, wireguard_listen_port_override: int, wireguard_endpoint_override: string}>
*/
private function servers(): array
{
return [
[
'name' => 'coold-dev',
'host' => 'lima-coold-dev',
'ssh_port' => 22,
'host' => 'host.docker.internal',
'ssh_port' => 60001,
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'host.lima.internal:51821',
],
[
'name' => 'coold-dev-2',
'host' => 'lima-coold-dev-2',
'ssh_port' => 22,
'host' => 'host.docker.internal',
'ssh_port' => 60002,
'wireguard_listen_port_override' => 51822,
'wireguard_endpoint_override' => 'host.lima.internal:51822',
],
];
}
+18
View File
@@ -13,6 +13,9 @@ containerd:
system: false
user: false
ssh:
localPort: {{COOLIFY_COOLD_VM_SSH_PORT}}
images:
- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
arch: "x86_64"
@@ -58,4 +61,19 @@ provision:
mkdir -p /etc/coolify /var/lib/coolify-dev
chmod 755 /etc/coolify
coolify_test_public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd"
install -d -m 700 /root/.ssh
touch /root/.ssh/authorized_keys
grep -qxF "$coolify_test_public_key" /root/.ssh/authorized_keys || echo "$coolify_test_public_key" >>/root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
target_home="$(getent passwd 501 | cut -d: -f6 || true)"
if [ -n "$target_home" ] && [ -d "$target_home" ]; then
owner="$(stat -c '%u:%g' "$target_home")"
install -d -m 700 -o "${owner%:*}" -g "${owner#*:}" "$target_home/.ssh"
touch "$target_home/.ssh/authorized_keys"
grep -qxF "$coolify_test_public_key" "$target_home/.ssh/authorized_keys" || echo "$coolify_test_public_key" >>"$target_home/.ssh/authorized_keys"
chown "$owner" "$target_home/.ssh/authorized_keys"
chmod 600 "$target_home/.ssh/authorized_keys"
fi
echo "[coold-vm] Minimal provisioning complete. coolify bootstrap will install WireGuard, Podman, Corrosion, coold, and builder."
+1
View File
@@ -19,6 +19,7 @@ services:
COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}"
COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}"
COOLIFY_CLI_VERSION: "${COOLIFY_CLI_VERSION:-nightly}"
COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"
COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}"
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}"
+8
View File
@@ -105,6 +105,14 @@ body {
@apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400;
}
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px;
}
}
body {
@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;
}
+8
View File
@@ -152,6 +152,14 @@
@apply font-sans;
}
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px;
}
}
body {
@apply bg-background text-foreground;
min-height: 100vh;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@ const buttonVariants = cva(
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
delete: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
+24 -3
View File
@@ -1,6 +1,8 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { XIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
@@ -21,18 +23,37 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
);
}
function DialogContent({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed left-1/2 top-1/2 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-lg outline-none',
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg outline-none',
className,
)}
{...props}
/>
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
aria-label="Close dialog"
render={<Button type="button" variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
type FieldProps = React.ComponentProps<'label'>;
function Field({ className, ...props }: FieldProps) {
return <label data-slot="field" className={cn('flex flex-col gap-1 text-sm', className)} {...props} />;
}
type FieldLabelProps = React.ComponentProps<'span'>;
function FieldLabel({ className, ...props }: FieldLabelProps) {
return <span data-slot="field-label" className={cn('font-medium text-foreground', className)} {...props} />;
}
type FieldErrorProps = React.ComponentProps<'span'> & {
message?: string;
};
function FieldError({ className, message, ...props }: FieldErrorProps) {
return (
<span
aria-hidden={message ? undefined : true}
aria-live="polite"
data-slot="field-error"
className={cn('min-h-4 text-xs leading-4 text-destructive', className)}
{...props}
>
{message ?? ''}
</span>
);
}
export { Field, FieldError, FieldLabel };
+18
View File
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, ...props }: React.ComponentProps<'input'>) {
return (
<input
data-slot="input"
className={cn(
'rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'min-h-24 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
);
}
export { Textarea };
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react';
export function usePendingIds<T extends string | number>() {
const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set());
const start = useCallback((id: T): void => {
setPendingIds((currentIds) => new Set(currentIds).add(id));
}, []);
const finish = useCallback((id: T): void => {
setPendingIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.delete(id);
return nextIds;
});
}, []);
const has = useCallback((id: T): boolean => pendingIds.has(id), [pendingIds]);
return {
pendingIds,
has,
hasAny: pendingIds.size > 0,
start,
finish,
};
}
+36 -2
View File
@@ -9,20 +9,48 @@ export type V5Server = {
id: string;
name: string;
host: string;
sshUser: string;
sshPort: number;
status: string;
capabilities: string[];
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
nodeAddress: string | null;
wireguardListenPortOverride: number | null;
wireguardEndpointOverride: string | null;
wireguardManagementIp: string | null;
wireguardPublicKey: string | null;
containerSubnets: Record<string, string> | string[];
privateKeyName: string | null;
lastBootstrappedAt: string | null;
lastStatusCheck: string | null;
lastStatusOutput: string | null;
lastStatusCheckedAt: string | null;
};
export type V5Cluster = {
id: string;
name: string;
description: string | null;
wireguardInterface: string;
wireguardManagementPool: string;
wireguardListenPort: number;
containerNetworkPool: string;
containerNetworkPrefix: number;
namespaces: string[];
defaultDenyContainers: boolean;
cooldVersion: string;
corrosionVersion: string;
corrosionGossipPort: number;
corrosionApiPort: number;
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
builderMemoryMax: string;
builderTimeoutSecs: number;
lastCliAction: string | null;
lastCliStatus: string | null;
lastCliSummary: string | null;
lastCliRanAt: string | null;
serversCount: number;
servers: V5Server[];
};
@@ -38,9 +66,15 @@ export type V5Project = {
environments: V5Environment[];
};
export type V5PrivateKey = {
id: string;
name: string;
};
export type V5DashboardProps = {
flux: FluxStatus | null;
clusters?: V5Cluster[];
privateKeys?: V5PrivateKey[];
projects?: V5Project[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
+6
View File
@@ -8,4 +8,10 @@ Route::middleware('v5.authenticated')->group(function () {
Route::post('/selection', [DashboardController::class, 'updateSelection'])->name('selection.update');
Route::get('/clusters', [DashboardController::class, 'clustersIndex'])->name('clusters.index');
Route::post('/clusters', [DashboardController::class, 'storeCluster'])->name('clusters.store');
Route::delete('/clusters/{cluster}', [DashboardController::class, 'destroyCluster'])->name('clusters.destroy');
Route::post('/clusters/{cluster}/servers', [DashboardController::class, 'storeServer'])->name('clusters.servers.store');
Route::patch('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'updateServer'])->name('clusters.servers.update');
Route::post('/clusters/{cluster}/servers/{server}/check', [DashboardController::class, 'checkServer'])->name('clusters.servers.check');
Route::post('/clusters/{cluster}/servers/{server}/bootstrap', [DashboardController::class, 'bootstrapServer'])->name('clusters.servers.bootstrap');
Route::delete('/clusters/{cluster}/servers/{server}', [DashboardController::class, 'destroyServer'])->name('clusters.servers.destroy');
});
+3
View File
@@ -29,6 +29,7 @@ VERSION="$(read_coolify_env COOLIFY_COOLD_VERSION nightly)"
CORROSION_VERSION="$(read_coolify_env COOLIFY_CORROSION_VERSION v1.0.0)"
FLUX_URL="$(read_coolify_env COOLIFY_COOLD_VM_FLUX_URL http://host.lima.internal:6443)"
BUILDER_CAPACITY="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)"
SSH_PORT="$(read_coolify_env COOLIFY_COOLD_VM_SSH_PORT 60002)"
WG_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_IP "")"
WG_PEER_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_IP "")"
WG_PEER_ENDPOINT="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_ENDPOINT "")"
@@ -69,6 +70,7 @@ Environment:
COOLIFY_CORROSION_VERSION corrosion release tag to install (default: v1.0.0)
COOLIFY_COOLD_VM_FLUX_URL Flux gRPC URL visible from the VM (default: http://host.lima.internal:6443)
COOLIFY_COOLD_VM_BUILDER_CAPACITY VM builder capacity to advertise (default: 2; set 0 to disable)
COOLIFY_COOLD_VM_SSH_PORT Host SSH port forwarded to this VM (default: 60002)
COOLIFY_COOLD_VM_WG_IP Optional WireGuard mgmt IP for this host
COOLIFY_COOLD_VM_CONTAINER_SUBNET Podman mesh subnet for this host
COOLIFY_COOLD_VM_CONTAINER_GATEWAY Podman mesh gateway for this host
@@ -395,6 +397,7 @@ generate_yaml() {
-e "s#{{COOLIFY_REPO}}#$ROOT#g" \
-e "s#{{COOLIFY_COOLD_VERSION}}#$VERSION#g" \
-e "s#{{COOLIFY_CORROSION_VERSION}}#$CORROSION_VERSION#g" \
-e "s#{{COOLIFY_COOLD_VM_SSH_PORT}}#$SSH_PORT#g" \
"$TEMPLATE" > "$GENERATED"
}
+45 -3
View File
@@ -60,6 +60,11 @@ coold_vm_wg_port() {
read_coolify_env "COOLIFY_COOLD_VM_WG_PORT_${index}" "$((51820 + index))"
}
coold_vm_ssh_port() {
local index="$1"
read_coolify_env "COOLIFY_COOLD_VM_SSH_PORT_${index}" "6000${index}"
}
coold_vm_container_subnet() {
local index="$1"
read_coolify_env "COOLIFY_COOLD_VM_CONTAINER_SUBNET_${index}" "10.210.$((index - 1)).0/24"
@@ -172,6 +177,28 @@ lima_ssh_config() {
printf '%s\n' "$config"
}
lima_ssh_port() {
local index="$1"
local instance
local port
instance="$(coold_vm_instance "$index")"
if [ ! -f "$HOME/.lima/${instance}/ssh.config" ]; then
echo "ERROR: Lima SSH config for ${instance} was not found. Start it first with scripts/dev.sh up." >&2
exit 1
fi
port="$(awk 'tolower($1) == "port" { print $2; exit }' "$HOME/.lima/${instance}/ssh.config")"
if [ -z "$port" ]; then
echo "ERROR: Lima SSH port for ${instance} was not found in $HOME/.lima/${instance}/ssh.config." >&2
exit 1
fi
printf '%s\n' "$port"
}
coolify_nodes_arg() {
local count
local nodes=""
@@ -346,6 +373,7 @@ coold_vm() {
shift
COOLIFY_COOLD_LIMA_INSTANCE="$(coold_vm_instance "$index")" \
COOLIFY_COOLD_VM_WG_IP="$(coold_vm_wg_ip "$index")" \
COOLIFY_COOLD_VM_SSH_PORT="$(coold_vm_ssh_port "$index")" \
COOLIFY_COOLD_VM_CONTAINER_SUBNET="$(coold_vm_container_subnet "$index")" \
COOLIFY_COOLD_VM_CONTAINER_GATEWAY="$(coold_vm_container_gateway "$index")" \
scripts/coold-vm.sh "$@"
@@ -417,17 +445,31 @@ follow_logs() {
}
sync_v5_dev_lima_servers() {
local count
local index
local instance
local server_args=()
local ssh_port
local ssh_user
count="$(coold_vm_count)"
ssh_user="$(coolify_ssh_user)"
for index in $(seq 1 "$count"); do
instance="$(coold_vm_instance "$index")"
ssh_port="$(lima_ssh_port "$index")"
server_args+=(--server="${instance}|host.docker.internal|${ssh_user}|${ssh_port}")
done
echo "==> Running pending migrations before syncing v5 dev Lima state..."
spin exec -T coolify php artisan migrate --force
echo "==> Seeding dev Lima VM(s) into v5 clusters/servers..."
spin exec -T \
-e COOLIFY_CLI_SSH_USER="$ssh_user" \
coolify php artisan db:seed --class=V5DevLimaSeeder --force
coolify php artisan v5:sync-dev-lima-servers \
--builder-capacity="$(read_coolify_env COOLIFY_COOLD_VM_BUILDER_CAPACITY 2)" \
"${server_args[@]}"
}
configure_flux_dev_for_vm() {
@@ -491,9 +533,9 @@ up() {
echo "==> Starting Coolify Docker stack with Spin..."
if [ "${#spin_args[@]}" -gt 0 ]; then
spin up -d "${spin_args[@]}"
COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" spin up -d "${spin_args[@]}"
else
spin up -d
COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" spin up -d
fi
if [ "$naked" = "true" ]; then
@@ -61,14 +61,57 @@ it('retries dev coolify bootstrap because fresh Lima setup can complete across p
it('seeds bootstrapped Lima VMs into v5 development server state', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
expect($script)->toContain('sync_v5_dev_lima_servers()')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$ssh_user"')
->and($script)->toContain('db:seed --class=V5DevLimaSeeder --force')
->and($script)->not->toContain('v5:sync-dev-lima-servers')
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" spin up -d')
->and($script)->toContain('lima_ssh_port "$index"')
->and($script)->toContain('host.docker.internal')
->and($script)->toContain('v5:sync-dev-lima-servers')
->and($script)->toContain('--server="${instance}|host.docker.internal|${ssh_user}|${ssh_port}"')
->and($compose)->toContain('COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"')
->and($script)->not->toContain('db:seed --class=V5DevLimaSeeder --force')
->and($script)->not->toContain('--server "${instance}|${node}|$(coolify_ssh_user)|22"');
});
it('configures predictable Lima SSH local ports for dev VMs', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($script)->toContain('coold_vm_ssh_port()')
->and($script)->toContain('COOLIFY_COOLD_VM_SSH_PORT_')
->and($script)->toContain('6000${index}')
->and($script)->toContain('COOLIFY_COOLD_VM_SSH_PORT="$(coold_vm_ssh_port "$index")"')
->and($vmScript)->toContain('SSH_PORT="$(read_coolify_env COOLIFY_COOLD_VM_SSH_PORT 60002)"')
->and($vmScript)->toContain('{{COOLIFY_COOLD_VM_SSH_PORT}}')
->and($template)->toContain('ssh:')
->and($template)->toContain('localPort: {{COOLIFY_COOLD_VM_SSH_PORT}}');
});
it('authorizes the seeded testing host key in dev Lima VMs', function () {
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
expect($template)->toContain('coolify_test_public_key=')
->and($template)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68')
->and($template)->toContain('authorized_keys')
->and($template)->toContain('grep -qxF "$coolify_test_public_key"')
->and($template)->toContain('install -d -m 700 /root/.ssh')
->and($template)->toContain('/root/.ssh/authorized_keys')
->and($template)->toContain('target_home="$(getent passwd 501 | cut -d: -f6 || true)"')
->and($template)->toContain('owner="$(stat -c \'%u:%g\' "$target_home")"')
->and($template)->not->toContain('mode: user')
->and($template)->not->toContain('user="$(basename "$home")"');
});
it('hardcodes the naked Lima VM ssh port for bootstrap testing', function () {
$template = file_get_contents(base_path('.dev/lima/coolify-naked-test.yaml'));
expect($template)->toContain('ssh:')
->and($template)->toContain('localPort: 60003');
});
it('supports down cleanup as the preferred VM cleanup command', function () {
$script = file_get_contents(base_path('scripts/dev.sh'));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
<?php
it('uses at least sixteen pixel form controls on mobile to prevent input focus zoom', function (string $stylesheet) {
$css = file_get_contents(dirname(__DIR__, 2).'/'.$stylesheet);
expect($css)->toContain('@media (max-width: 767px)')
->and($css)->toContain('input,')
->and($css)->toContain('textarea,')
->and($css)->toContain('select')
->and($css)->toContain('font-size: 16px');
})->with([
'current interface' => 'resources/css/app.css',
'v5 interface' => 'resources/css/v5/app.css',
]);
it('keeps user zoom enabled in the viewport meta tags', function () {
$layouts = [
'resources/views/layouts/base.blade.php',
'resources/views/v5/app.blade.php',
];
foreach ($layouts as $layout) {
expect(file_get_contents(dirname(__DIR__, 2).'/'.$layout))
->not->toContain('maximum-scale')
->not->toContain('user-scalable=no');
}
});