mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 04:24:23 +00:00
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:
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+90
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1290
-58
File diff suppressed because it is too large
Load Diff
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
|
||||
+1207
-21
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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user