feat(v5): add mesh app canvas

This commit is contained in:
Andras Bacsai
2026-06-20 09:23:16 +02:00
parent 155b07378c
commit 86156b6f7a
53 changed files with 6037 additions and 113 deletions
+1
View File
@@ -3,6 +3,7 @@ APP_ENV=local
APP_NAME=Coolify
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=development-flux-token
APP_URL=http://localhost
APP_PORT=8000
APP_DEBUG=true
+1
View File
@@ -2,6 +2,7 @@ APP_ENV=production
APP_NAME="Coolify Staging"
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_URL=http://localhost
APP_PORT=8000
SSH_MUX_ENABLED=true
+1
View File
@@ -1,6 +1,7 @@
APP_ID=
APP_NAME=Coolify
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=
DB_USERNAME=coolify
DB_PASSWORD=
+1
View File
@@ -1,5 +1,6 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_DEBUG=true
DB_CONNECTION=testing
@@ -0,0 +1,137 @@
<?php
namespace App\Actions\V5\Application;
use App\Models\PrivateKey;
use App\Models\V5\Application;
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class DeployNginxApplication
{
use AsAction;
public function handle(Application $application): Application
{
$application->loadMissing('server.privateKey');
$server = $application->server;
if ($server === null) {
return $this->markFailed($application, 'No server is attached to this application.');
}
if (! $server->privateKey instanceof PrivateKey) {
return $this->markFailed($application, 'No private key is attached to this server.');
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$result = Process::timeout(120)->run([
'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,
"{$server->ssh_user}@{$server->host}",
$this->remoteCommand($application),
]);
if (! $result->successful()) {
return $this->markFailed($application, $this->processOutput($result));
}
$containerId = trim($result->output());
$application->update([
'status' => 'running',
'status_message' => 'Container started.',
'runtime_container_id' => $containerId !== '' ? $containerId : null,
]);
return $application->refresh()->load('server');
} catch (\Throwable $e) {
return $this->markFailed($application, $e->getMessage());
} finally {
@unlink($keyLocation);
}
}
private function remoteCommand(Application $application): string
{
$image = escapeshellarg($application->image);
$containerName = escapeshellarg($application->container_name);
$network = escapeshellarg($this->meshNetwork($application));
return implode(PHP_EOL, [
'set -e',
'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi',
'if ! $podman --version >/dev/null 2>&1; then echo "Rootful Podman is required for v5 mesh applications." >&2; exit 1; fi',
"if ! \$podman network exists {$network}; then echo 'Mesh network {$network} does not exist. Bootstrap this server into the v5 mesh first.' >&2; exit 1; fi",
"container_id=\$(\$podman run -d --replace --name {$containerName} --network {$network} --network-alias {$containerName} {$image})",
'sleep 1',
"is_running=$(\$podman inspect -f '{{.State.Running}}' {$containerName} 2>/dev/null || printf false)",
'if [ "$is_running" != "true" ]; then',
" echo 'Container did not stay running.' >&2",
" \$podman ps -a --filter name={$containerName} >&2 || true",
' exit 1',
'fi',
'printf %s "$container_id"',
]);
}
private function meshNetwork(Application $application): string
{
$namespace = $application->mesh_namespace ?: 'default';
return "coolify-{$namespace}-mesh";
}
private function processOutput(ProcessResult $result): string
{
$output = trim($result->output()."\n".$result->errorOutput());
return $output !== '' ? $output : 'Could not start nginx container.';
}
private function markFailed(Application $application, string $message): Application
{
$application->update([
'status' => 'failed',
'status_message' => str($message)->limit(10000)->toString(),
]);
return $application->refresh()->load('server');
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_nginx_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Actions\V5\Application;
use App\Models\PrivateKey;
use App\Models\V5\Application;
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class DestroyNginxApplication
{
use AsAction;
public function handle(Application $application): ?string
{
$application->loadMissing('server.privateKey');
$server = $application->server;
if ($server === null || ! $server->privateKey instanceof PrivateKey) {
return null;
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$result = Process::timeout(120)->run([
'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,
"{$server->ssh_user}@{$server->host}",
$this->remoteCommand($application),
]);
if (! $result->successful()) {
return $this->processOutput($result);
}
return null;
} catch (\Throwable $e) {
return $e->getMessage();
} finally {
@unlink($keyLocation);
}
}
private function remoteCommand(Application $application): string
{
$containerName = escapeshellarg($application->container_name);
return implode(PHP_EOL, [
'set -e',
'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi',
"\$podman rm -f {$containerName} >/dev/null 2>&1 || true",
]);
}
private function processOutput(ProcessResult $result): string
{
$output = trim($result->output()."\n".$result->errorOutput());
return $output !== '' ? $output : 'Could not delete nginx container.';
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_nginx_destroy_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
}
}
@@ -0,0 +1,268 @@
<?php
namespace App\Actions\V5\Flux;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ContainerStatus;
use App\Models\V5\Server as V5Server;
use Illuminate\Database\Eloquent\Model;
use Lorisleiva\Actions\Concerns\AsAction;
class ApplyFluxResourceStatusUpdate
{
use AsAction;
/**
* @param array<string, mixed> $payload
*/
public function handle(array $payload): ?Model
{
$resourceType = strtolower((string) data_get($payload, 'resource_type', data_get($payload, 'type', '')));
$containerStatus = $resourceType === 'container' ? $this->upsertContainerStatus($payload) : null;
if ($this->isCaddyIngressStatusUpdate($payload, $resourceType)) {
return $this->updateCaddyIngress($payload) ?? $containerStatus;
}
if (in_array($resourceType, ['server', 'node', 'host'], true)) {
return $this->updateServer($payload);
}
return $this->updateApplication($payload) ?? $containerStatus;
}
/**
* @param array<string, mixed> $payload
*/
private function upsertContainerStatus(array $payload): ?ContainerStatus
{
$status = $this->status($payload);
$containerId = $this->stringValue($payload, 'container_id') ?? $this->stringValue($payload, 'runtime_container_id');
$server = $this->findServer($payload);
if ($status === null || $containerId === null || ! $server instanceof V5Server) {
return null;
}
ContainerStatus::query()->updateOrCreate([
'server_id' => $server->id,
'container_id' => $containerId,
], [
'team_id' => $server->team_id,
'container_name' => $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'),
'image' => $this->stringValue($payload, 'image'),
'status' => $status,
'status_message' => $this->statusMessage($payload, 'Container state received from coold.'),
'last_seen_at' => now(),
]);
return ContainerStatus::query()
->where('server_id', $server->id)
->where('container_id', $containerId)
->first();
}
/**
* @param array<string, mixed> $payload
*/
private function updateApplication(array $payload): ?V5Application
{
$status = $this->status($payload);
if ($status === null) {
return null;
}
$application = $this->findApplication($payload);
if (! $application instanceof V5Application) {
return null;
}
$application->update([
'status' => $status,
'status_message' => $this->statusMessage($payload, 'Status updated by flux.'),
'runtime_container_id' => $this->stringValue($payload, 'runtime_container_id')
?? $this->stringValue($payload, 'container_id')
?? $application->runtime_container_id,
]);
return $application->refresh();
}
/**
* @param array<string, mixed> $payload
*/
private function updateServer(array $payload): ?V5Server
{
$status = $this->status($payload);
if ($status === null) {
return null;
}
$server = $this->findServer($payload);
if (! $server instanceof V5Server) {
return null;
}
$server->update([
'status' => $status,
'last_status_check' => 'flux',
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
'last_status_checked_at' => now(),
]);
return $server->refresh();
}
/**
* @param array<string, mixed> $payload
*/
private function updateCaddyIngress(array $payload): ?V5Server
{
$status = $this->status($payload);
if ($status === null) {
return null;
}
$server = $this->findServer($payload);
if (! $server instanceof V5Server || ! $server->isIngress()) {
return null;
}
$server->update([
'caddy_ingress_status' => $status,
'last_status_check' => 'flux',
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
'last_status_checked_at' => now(),
]);
return $server->refresh();
}
/**
* @param array<string, mixed> $payload
*/
private function findApplication(array $payload): ?V5Application
{
$query = V5Application::query()->with('server');
$teamId = $this->intValue($payload, 'team_id');
$server = $this->findServer($payload);
if ($teamId !== null) {
$query->where('team_id', $teamId);
}
if ($server instanceof V5Server) {
$query->where('server_id', $server->id);
}
$applicationId = $this->intValue($payload, 'application_id') ?? $this->intValue($payload, 'resource_id');
if ($applicationId !== null) {
return $query->whereKey($applicationId)->first();
}
$containerName = $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name');
if ($containerName !== null) {
return $query->where('container_name', $containerName)->first();
}
$containerId = $this->stringValue($payload, 'runtime_container_id') ?? $this->stringValue($payload, 'container_id');
if ($containerId !== null) {
return $query->where('runtime_container_id', $containerId)->first();
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function isCaddyIngressStatusUpdate(array $payload, string $resourceType): bool
{
if (in_array($resourceType, ['caddy_ingress', 'caddy-ingress'], true)) {
return true;
}
return $this->stringValue($payload, 'container_name') === 'coolify-v5-caddy'
|| $this->stringValue($payload, 'name') === 'coolify-v5-caddy';
}
/**
* @param array<string, mixed> $payload
*/
private function findServer(array $payload): ?V5Server
{
$serverId = $this->intValue($payload, 'server_id') ?? $this->intValue($payload, 'host_server_id');
if ($serverId !== null) {
return V5Server::query()->find($serverId);
}
$hostId = $this->stringValue($payload, 'host_id')
?? $this->stringValue($payload, 'node_id')
?? $this->stringValue($payload, 'server_host');
if ($hostId === null) {
return null;
}
return V5Server::query()
->where('wireguard_management_ip', $hostId)
->orWhere('node_address', $hostId)
->orWhere('host', $hostId)
->first();
}
/**
* @param array<string, mixed> $payload
*/
private function status(array $payload): ?string
{
$status = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state');
return $status === null ? null : strtolower($status);
}
/**
* @param array<string, mixed> $payload
*/
private function statusMessage(array $payload, string $fallback): string
{
return $this->stringValue($payload, 'status_message')
?? $this->stringValue($payload, 'message')
?? $fallback;
}
/**
* @param array<string, mixed> $payload
*/
private function stringValue(array $payload, string $key): ?string
{
$value = data_get($payload, $key);
return is_string($value) && $value !== '' ? $value : null;
}
/**
* @param array<string, mixed> $payload
*/
private function intValue(array $payload, string $key): ?int
{
$value = data_get($payload, $key);
if (is_int($value)) {
return $value;
}
return is_string($value) && ctype_digit($value) ? (int) $value : null;
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Actions\V5\Proxy;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class GenerateCaddyIngressConfiguration
{
use AsAction;
/**
* @return array{compose: string, caddyfile: string, commands: array<int, string>}
*/
public function handle(string $basePath = '/data/coolify/v5/ingress/caddy'): array
{
$compose = Yaml::dump([
'services' => [
'caddy' => [
'image' => 'docker.io/library/caddy:2-alpine',
'container_name' => 'coolify-v5-caddy',
'restart' => 'unless-stopped',
'ports' => [
'80:80',
'443:443',
'443:443/udp',
],
'volumes' => [
'./Caddyfile:/etc/caddy/Caddyfile:ro',
'./data:/data',
'./config:/config',
],
],
],
], 8, 2);
$caddyfile = <<<'CADDY'
:80 {
respond /coolify-health 200
respond 404
}
CADDY;
return [
'compose' => $compose,
'caddyfile' => $caddyfile,
'commands' => [
sprintf('if [ "$(id -u)" = "0" ]; then mkdir -p %1$s/data %1$s/config; else sudo mkdir -p %1$s/data %1$s/config; fi', $basePath),
sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/docker-compose.yml > /dev/null; else sudo tee %s/docker-compose.yml > /dev/null; fi", base64_encode($compose), $basePath, $basePath),
sprintf("printf '%%s' '%s' | base64 -d | if [ \"\$(id -u)\" = \"0\" ]; then tee %s/Caddyfile > /dev/null; else sudo tee %s/Caddyfile > /dev/null; fi", base64_encode($caddyfile), $basePath, $basePath),
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime pull docker.io/library/caddy:2-alpine',
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true',
"if command -v podman >/dev/null 2>&1; then runtime=\"sudo podman\"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo \"Neither podman nor docker is installed\" >&2; exit 1; fi; \$runtime run -d --name coolify-v5-caddy --restart unless-stopped -p 80:80 -p 443:443 -p 443:443/udp -v {$basePath}/Caddyfile:/etc/caddy/Caddyfile:ro -v {$basePath}/data:/data -v {$basePath}/config:/config docker.io/library/caddy:2-alpine",
],
];
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Actions\V5\Proxy;
use App\Models\PrivateKey;
use App\Models\V5\Server;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class StartCaddyIngress
{
use AsAction;
public function handle(Server $server): string
{
$server->loadMissing('privateKey');
if (! $server->isIngress()) {
return 'Server is not an ingress server.';
}
if (! $server->privateKey instanceof PrivateKey) {
return 'No private key is attached to this server.';
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$commands = GenerateCaddyIngressConfiguration::run()['commands'];
$result = Process::timeout(180)->run([
'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,
"{$server->ssh_user}@{$server->host}",
implode("\n", $commands),
]);
$output = trim($result->output()."\n".$result->errorOutput());
if ($result->failed()) {
$server->update(['caddy_ingress_status' => 'failed']);
throw new \RuntimeException('Failed to start Caddy ingress: '.($output !== '' ? $output : 'No output returned.'));
}
$server->update(['caddy_ingress_status' => 'running']);
return $output !== '' ? $output : 'Caddy ingress started.';
} finally {
@unlink($keyLocation);
}
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_caddy_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Actions\V5\Proxy;
use App\Models\PrivateKey;
use App\Models\V5\Server;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class StopCaddyIngress
{
use AsAction;
public function handle(Server $server): string
{
$server->loadMissing('privateKey');
if (! $server->privateKey instanceof PrivateKey) {
return 'No private key is attached to this server.';
}
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
try {
$result = Process::timeout(60)->run([
'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,
"{$server->ssh_user}@{$server->host}",
'if command -v podman >/dev/null 2>&1; then runtime="sudo podman"; elif command -v docker >/dev/null 2>&1; then runtime=docker; else echo "Neither podman nor docker is installed" >&2; exit 1; fi; $runtime rm -f coolify-v5-caddy 2>/dev/null || true',
]);
$output = trim($result->output()."\n".$result->errorOutput());
if ($result->failed()) {
throw new \RuntimeException('Failed to stop Caddy ingress: '.($output !== '' ? $output : 'No output returned.'));
}
$server->update(['caddy_ingress_status' => 'exited']);
return $output !== '' ? $output : 'Caddy ingress stopped.';
} finally {
@unlink($keyLocation);
}
}
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
{
$keyDirectory = storage_path('app/ssh/keys');
if (! is_dir($keyDirectory)) {
mkdir($keyDirectory, 0700, true);
}
$keyLocation = tempnam($keyDirectory, 'v5_caddy_key_');
if ($keyLocation === false) {
throw new \RuntimeException('Could not create a temporary SSH key file.');
}
file_put_contents($keyLocation, $privateKey->private_key);
chmod($keyLocation, 0600);
return $keyLocation;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Actions\V5\Server;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class SyncDevLimaServers
{
use AsAction;
/**
* @param array<int, array{
* name: string,
* host: string,
* ssh_user: string,
* ssh_port: int,
* wireguard_management_ip?: ?string,
* wireguard_listen_port_override?: ?int,
* wireguard_endpoint_override?: ?string
* }> $servers
*/
public function handle(
Team $team,
User $user,
?PrivateKey $privateKey,
string $clusterName,
int $builderCapacity,
array $servers,
): Cluster {
$cluster = Cluster::query()->updateOrCreate([
'team_id' => $team->id,
'name' => $clusterName,
], [
'created_by_user_id' => $user->id,
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
$builderCapacity = max(0, $builderCapacity);
$builderEnabled = $builderCapacity > 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
foreach ($servers as $server) {
$wireguardManagementIp = $server['wireguard_management_ip'] ?? null;
$values = [
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey?->id,
'host' => $server['host'],
'ssh_user' => $server['ssh_user'],
'ssh_port' => $server['ssh_port'],
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'node_address' => $wireguardManagementIp ?: $server['host'],
'wireguard_management_ip' => $wireguardManagementIp,
'last_bootstrapped_at' => now(),
];
if (array_key_exists('wireguard_listen_port_override', $server)) {
$values['wireguard_listen_port_override'] = $server['wireguard_listen_port_override'];
}
if (array_key_exists('wireguard_endpoint_override', $server)) {
$values['wireguard_endpoint_override'] = $server['wireguard_endpoint_override'];
}
Server::query()->updateOrCreate([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
'name' => $server['name'],
], $values);
}
return $cluster->refresh();
}
}
+22 -30
View File
@@ -2,11 +2,10 @@
namespace App\Console\Commands;
use App\Actions\V5\Server\SyncDevLimaServers;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server;
use Illuminate\Console\Command;
class V5SyncDevLimaServers extends Command
@@ -17,7 +16,7 @@ class V5SyncDevLimaServers extends Command
{--private-key-id= : Optional private key used by the dev servers}
{--cluster=Development-Lima : Cluster name for the dev Lima servers}
{--builder-capacity=2 : Builder capacity to record for each dev server}
{--server=* : Server as name|host|ssh_user|ssh_port}
{--server=* : Server as name|host|ssh_user|ssh_port|wireguard_management_ip}
{--force : Allow running outside local/development environments}';
protected $description = 'Sync development Lima VMs into the v5 server/cluster tables.';
@@ -55,47 +54,40 @@ class V5SyncDevLimaServers extends Command
return self::SUCCESS;
}
$cluster = Cluster::query()->updateOrCreate([
'team_id' => $team->id,
'name' => (string) $this->option('cluster'),
], [
'created_by_user_id' => $user->id,
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
$builderCapacity = max(0, (int) $this->option('builder-capacity'));
$builderEnabled = $builderCapacity > 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
$parsedServers = [];
foreach ($servers as $server) {
$parts = explode('|', (string) $server);
if (count($parts) !== 4) {
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port.");
if (! in_array(count($parts), [4, 5], true)) {
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port|wireguard_management_ip.");
return self::FAILURE;
}
[$name, $host, $sshUser, $sshPort] = $parts;
[$name, $host, $sshUser, $sshPort] = array_slice($parts, 0, 4);
$wireguardManagementIp = ($parts[4] ?? null) ?: null;
Server::query()->updateOrCreate([
'team_id' => $team->id,
'cluster_id' => $cluster->id,
$parsedServers[] = [
'name' => $name,
], [
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey?->id,
'host' => $host,
'ssh_user' => $sshUser,
'ssh_port' => (int) $sshPort,
'status' => 'installed',
'capabilities' => $capabilities,
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'last_bootstrapped_at' => now(),
]);
'wireguard_management_ip' => $wireguardManagementIp,
];
}
$this->info("Synced {$name} ({$host}:{$sshPort}).");
SyncDevLimaServers::run(
team: $team,
user: $user,
privateKey: $privateKey,
clusterName: (string) $this->option('cluster'),
builderCapacity: (int) $this->option('builder-capacity'),
servers: $parsedServers,
);
foreach ($parsedServers as $server) {
$this->info("Synced {$server['name']} ({$server['host']}:{$server['ssh_port']}).");
}
return self::SUCCESS;
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Events;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class V5CanvasResourceUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $teamId,
public ?int $applicationId = null,
public ?int $caddyIngressServerId = null,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
public function broadcastAs(): string
{
return 'v5.canvas.resource.updated';
}
/**
* @return array{application: array<string, mixed>|null, caddyIngress: array<string, mixed>|null}
*/
public function broadcastWith(): array
{
$application = $this->applicationId !== null
? V5Application::query()->with('server')->find($this->applicationId)
: null;
$caddyIngress = $this->caddyIngressServerId !== null
? V5Server::query()->find($this->caddyIngressServerId)
: null;
return [
'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null,
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
? $this->serializeCaddyIngress($caddyIngress)
: null,
];
}
/**
* @return array<string, mixed>
*/
private function serializeApplication(V5Application $application): array
{
return [
'id' => (string) $application->id,
'name' => $application->name,
'image' => $application->image,
'containerName' => $application->container_name,
'status' => $application->status,
'statusMessage' => $application->status_message,
'runtimeContainerId' => $application->runtime_container_id,
'serverName' => $application->server?->name,
'meshNamespace' => $application->mesh_namespace,
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
'canvasX' => $application->canvas_x,
'canvasY' => $application->canvas_y,
];
}
/**
* @return array<string, mixed>
*/
private function serializeCaddyIngress(V5Server $server): array
{
return [
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->caddyIngressStatus(),
'canvasX' => $server->canvas_x ?? -352,
'canvasY' => $server->canvas_y ?? 0,
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Internal;
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class FluxResourceStatusController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$token = config('flux.laravel_api_token');
if (! is_string($token) || $token === '' || ! hash_equals($token, (string) $request->bearerToken())) {
abort(401);
}
$validated = Validator::make($request->all(), [
'resource_type' => ['required', 'string', 'max:64'],
'team_id' => ['nullable', 'integer'],
'application_id' => ['nullable', 'integer'],
'resource_id' => ['nullable', 'integer'],
'host_id' => ['nullable', 'string', 'max:255'],
'node_id' => ['nullable', 'string', 'max:255'],
'server_host' => ['nullable', 'string', 'max:255'],
'server_id' => ['nullable', 'integer'],
'host_server_id' => ['nullable', 'integer'],
'container_id' => ['nullable', 'string', 'max:255'],
'runtime_container_id' => ['nullable', 'string', 'max:255'],
'container_name' => ['nullable', 'string', 'max:255'],
'name' => ['nullable', 'string', 'max:255'],
'status' => ['required_without:state', 'string', 'max:64'],
'state' => ['required_without:status', 'string', 'max:64'],
'status_message' => ['nullable', 'string', 'max:1000'],
'message' => ['nullable', 'string', 'max:1000'],
])->validate();
$resource = ApplyFluxResourceStatusUpdate::run($validated);
if ($resource === null) {
if (($validated['resource_type'] ?? null) === 'container') {
return response()->json([
'message' => 'Container status accepted.',
], 202);
}
return response()->json([
'message' => 'No matching v5 resource was found.',
], 404);
}
return response()->json([
'message' => 'Resource status updated.',
]);
}
}
+711 -8
View File
@@ -2,6 +2,10 @@
namespace App\Http\Controllers\V5;
use App\Actions\V5\Application\DeployNginxApplication;
use App\Actions\V5\Application\DestroyNginxApplication;
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Events\V5ClusterUpdated;
use App\Events\V5RealtimeTestEvent;
use App\Http\Controllers\Controller;
@@ -10,20 +14,32 @@ use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Team;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Services\Flux\FluxClient;
use App\Services\Flux\FluxHealth;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
private const CANVAS_CARD_WIDTH = 320;
private const CANVAS_CARD_HEIGHT = 144;
private const CANVAS_CARD_GAP = 32;
private const SELECTED_PROJECT_SESSION_KEY = 'v5.selectedProjectUuid';
private const SELECTED_ENVIRONMENT_SESSION_KEY = 'v5.selectedEnvironmentUuid';
@@ -35,7 +51,12 @@ class DashboardController extends Controller
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
return Inertia::render('Dashboard', [
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
'flux' => $fluxHealth->check(),
'applications' => $this->applications($currentTeam, $selectedProject, $selectedEnvironment),
'caddyIngresses' => $this->caddyIngresses($currentTeam),
'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment),
'nginxServers' => $this->nginxServers($currentTeam),
'projects' => $projects,
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
@@ -49,6 +70,7 @@ class DashboardController extends Controller
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
return Inertia::render('Clusters', [
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
'flux' => $fluxHealth->check(),
'clusters' => $this->clusters($currentTeam),
'privateKeys' => $this->privateKeys($currentTeam),
@@ -139,6 +161,391 @@ class DashboardController extends Controller
return response()->noContent();
}
public function storeNginxApplication(Request $request): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team) {
abort(403);
}
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
if ($selectedProject === null || $selectedEnvironment === null) {
return response()->json([
'message' => 'Select a project and environment before deploying nginx.',
], 422);
}
$project = $this->projectQuery($currentTeam)
->where('uuid', $selectedProject['uuid'])
->first();
if (! $project instanceof Project) {
abort(403);
}
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
if (! $environment instanceof Environment) {
abort(403);
}
$validated = $request->validate([
'server_id' => ['nullable', 'integer'],
]);
$server = V5Server::query()
->where('team_id', $currentTeam->id)
->when(
isset($validated['server_id']),
fn (Builder $query) => $query->whereKey($validated['server_id']),
fn (Builder $query) => $query
->orderByRaw('last_bootstrapped_at is null')
->orderBy('name')
)
->first();
if (! $server instanceof V5Server) {
return response()->json([
'message' => 'Add a v5 server before deploying nginx.',
], 422);
}
$canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment);
$application = V5Application::query()->create([
'team_id' => $currentTeam->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'server_id' => $server->id,
'created_by_user_id' => $request->user()->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
'status' => 'creating',
'status_message' => 'Starting nginx container.',
'mesh_namespace' => 'default',
'canvas_x' => $canvasPosition['canvas_x'],
'canvas_y' => $canvasPosition['canvas_y'],
]);
$application = DeployNginxApplication::run($application);
return response()->json([
'application' => $this->serializeApplication($application),
], $application->status === 'running' ? 201 : 422);
}
public function refreshApplications(Request $request, FluxClient $fluxClient): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team) {
abort(403);
}
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
if ($selectedProject === null || $selectedEnvironment === null) {
return response()->json([
'message' => 'Select a project and environment before refreshing applications.',
], 422);
}
$applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
->with('server')
->get();
$errors = [];
$applications
->groupBy('server_id')
->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void {
/** @var V5Application|null $firstApplication */
$firstApplication = $serverApplications->first();
$server = $firstApplication?->server;
$hostId = $server?->wireguard_management_ip ?: $server?->node_address;
if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') {
$errors[] = 'A server is missing its Flux host id.';
return;
}
try {
$containers = collect($fluxClient->listContainers($hostId));
} catch (\Throwable $e) {
$errors[] = $e->getMessage();
return;
}
$serverApplications->each(function (V5Application $application) use ($containers): void {
$container = $containers->first(function (array $container) use ($application): bool {
return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id)
|| ($container['name'] ?? null) === $application->container_name;
});
if (! is_array($container)) {
$application->update([
'status' => 'exited',
'status_message' => 'Container not found on server.',
]);
return;
}
$state = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : 'unknown';
$application->update([
'status' => strtolower($state),
'status_message' => 'Container state refreshed from coold.',
'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id,
]);
});
});
V5Server::query()
->where('team_id', $currentTeam->id)
->orderBy('name')
->get()
->filter(fn (V5Server $server) => $server->isIngress())
->each(function (V5Server $server) use ($fluxClient, &$errors): void {
$hostId = $server->wireguard_management_ip ?: $server->node_address;
if (! is_string($hostId) || $hostId === '') {
$errors[] = "Caddy ingress server {$server->name} is missing its Flux host id.";
return;
}
try {
$containers = collect($fluxClient->listContainers($hostId));
} catch (\Throwable $e) {
$errors[] = $e->getMessage();
return;
}
$container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy');
$state = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== ''
? strtolower($container['state'])
: 'exited';
$server->update([
'caddy_ingress_status' => $state,
'last_status_check' => 'flux',
'last_status_output' => 'Caddy ingress state refreshed from coold.',
'last_status_checked_at' => now(),
]);
});
return response()->json([
'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
->with('server')
->orderBy('created_at')
->get()
->map(fn (V5Application $application) => $this->serializeApplication($application))
->all(),
'caddyIngresses' => $this->caddyIngresses($currentTeam),
'errors' => $errors,
]);
}
public function updateApplicationPosition(Request $request, V5Application $application): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) {
abort(404);
}
$validated = $request->validate([
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
]);
$application->update([
'canvas_x' => $validated['canvas_x'],
'canvas_y' => $validated['canvas_y'],
]);
return response()->json([
'application' => $this->serializeApplication($application->refresh()->load('server')),
]);
}
public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $server->team_id !== $currentTeam->id || ! $server->isIngress()) {
abort(404);
}
$validated = $request->validate([
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
]);
$server->update([
'canvas_x' => $validated['canvas_x'],
'canvas_y' => $validated['canvas_y'],
]);
return response()->json([
'caddyIngress' => $this->serializeCaddyIngress($server->refresh()),
]);
}
public function destroyApplication(Request $request, V5Application $application): \Illuminate\Http\Response|JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $application->team_id !== $currentTeam->id) {
abort(404);
}
$error = DestroyNginxApplication::run($application);
if ($error !== null) {
return response()->json([
'message' => $error,
], 422);
}
$application->delete();
return response()->noContent();
}
public function storeResourceConnection(Request $request): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team) {
abort(403);
}
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
if ($selectedProject === null || $selectedEnvironment === null) {
return response()->json([
'message' => 'Select a project and environment before connecting resources.',
], 422);
}
$project = $this->projectQuery($currentTeam)
->where('uuid', $selectedProject['uuid'])
->first();
if (! $project instanceof Project) {
abort(403);
}
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
if (! $environment instanceof Environment) {
abort(403);
}
$validated = $request->validate([
'resource_one' => ['required', 'array'],
'resource_one.type' => ['required', 'string', Rule::in(['application'])],
'resource_one.id' => ['required', 'integer'],
'resource_two' => ['required', 'array'],
'resource_two.type' => ['required', 'string', Rule::in(['application'])],
'resource_two.id' => ['required', 'integer'],
]);
$resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']);
$resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']);
if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) {
return response()->json([
'message' => 'A resource cannot connect to itself.',
], 422);
}
$connection = ResourceConnection::query()->firstOrCreate(
[
'team_id' => $currentTeam->id,
'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo),
],
[
'project_id' => $project->id,
'environment_id' => $environment->id,
'resource_one_type' => $resourceOne->getMorphClass(),
'resource_one_id' => $resourceOne->getKey(),
'resource_two_type' => $resourceTwo->getMorphClass(),
'resource_two_id' => $resourceTwo->getKey(),
'created_by_user_id' => $request->user()->id,
],
);
return response()->json([
'connection' => $this->serializeResourceConnection($connection->load('rules')),
], $connection->wasRecentlyCreated ? 201 : 200);
}
public function updateResourceConnection(Request $request, ResourceConnection $connection): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $connection->team_id !== $currentTeam->id) {
abort(404);
}
$validated = $request->validate([
'ports_by_direction' => ['present', 'array'],
'ports_by_direction.*' => ['array'],
'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'],
]);
DB::transaction(function () use ($connection, $validated): void {
$connection->rules()->delete();
foreach ($validated['ports_by_direction'] as $direction => $ports) {
[$sourceResourceId, $targetResourceId] = array_pad(explode('->', (string) $direction, 2), 2, null);
if (! $this->connectionHasResourceId($connection, $sourceResourceId) || ! $this->connectionHasResourceId($connection, $targetResourceId)) {
continue;
}
foreach (array_unique($ports) as $port) {
$connection->rules()->create([
'source_resource_type' => $this->resourceTypeForConnectionId($connection, (int) $sourceResourceId),
'source_resource_id' => (int) $sourceResourceId,
'target_resource_type' => $this->resourceTypeForConnectionId($connection, (int) $targetResourceId),
'target_resource_id' => (int) $targetResourceId,
'protocol' => 'tcp',
'port' => (int) $port,
]);
}
}
});
return response()->json([
'connection' => $this->serializeResourceConnection($connection->refresh()->load('rules')),
]);
}
public function destroyResourceConnection(Request $request, ResourceConnection $connection): \Illuminate\Http\Response
{
$currentTeam = $request->attributes->get('v5.currentTeam');
if (! $currentTeam instanceof Team || $connection->team_id !== $currentTeam->id) {
abort(404);
}
$connection->delete();
return response()->noContent();
}
public function storeCluster(Request $request): JsonResponse
{
$currentTeam = $request->attributes->get('v5.currentTeam');
@@ -287,9 +694,11 @@ class DashboardController extends Controller
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
'wireguard_endpoint_override' => ['nullable', 'string', 'max:255'],
'ingress_enabled' => ['sometimes', 'boolean'],
]);
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false);
$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']);
@@ -304,7 +713,7 @@ class DashboardController extends Controller
'ssh_port' => $validated['ssh_port'],
'private_key_id' => $validated['private_key_id'] ?? null,
'status' => 'added',
'capabilities' => $builderEnabled ? ['coold', 'builder'] : ['coold'],
'capabilities' => $this->serverCapabilities($builderEnabled, $ingressEnabled),
'builder_enabled' => $builderEnabled,
'builder_capacity' => $builderCapacity,
'builder_cpu_quota' => $builderCpuQuota,
@@ -343,16 +752,13 @@ class DashboardController extends Controller
required: true
),
'builder_cpu_quota' => ['required', 'string', 'max:32'],
'ingress_enabled' => ['sometimes', 'boolean'],
]);
$wasIngress = $server->isIngress();
$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();
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress);
$capabilities = $this->serverCapabilities($builderEnabled, $ingressEnabled);
$server->update([
'capabilities' => $capabilities,
@@ -361,6 +767,9 @@ class DashboardController extends Controller
'builder_cpu_quota' => $validated['builder_cpu_quota'],
]);
$server->refresh();
$this->reconcileCaddyIngress($server, $wasIngress, $ingressEnabled);
$cluster->load(['servers' => fn ($query) => $query
->with('privateKey')
->orderBy('name')]);
@@ -712,6 +1121,269 @@ class DashboardController extends Controller
->implode(',');
}
/**
* @return array<int, array{id: string, name: string, host: string, status: string}>
*/
/**
* @return array{id: int}|null
*/
private function serializeCurrentTeam(mixed $currentTeam): ?array
{
if (! $currentTeam instanceof Team) {
return null;
}
return [
'id' => $currentTeam->id,
];
}
private function nginxServers(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return V5Server::query()
->where('team_id', $currentTeam->id)
->orderByRaw('last_bootstrapped_at is null')
->orderBy('name')
->get(['id', 'name', 'host', 'status'])
->map(fn (V5Server $server) => [
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->status,
])
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function applications(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
{
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
return [];
}
return $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
->with('server')
->orderBy('created_at')
->get()
->map(fn (V5Application $application) => $this->serializeApplication($application))
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function caddyIngresses(mixed $currentTeam): array
{
if (! $currentTeam instanceof Team) {
return [];
}
return V5Server::query()
->where('team_id', $currentTeam->id)
->orderBy('name')
->get()
->filter(fn (V5Server $server) => $server->isIngress())
->values()
->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index))
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function resourceConnections(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
{
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
return [];
}
return ResourceConnection::query()
->where('team_id', $currentTeam->id)
->whereHas('project', fn (Builder $query) => $query
->where('team_id', $currentTeam->id)
->where('uuid', $selectedProject['uuid']))
->whereHas('environment', fn (Builder $query) => $query
->where('uuid', $selectedEnvironment['uuid']))
->with('rules')
->orderBy('id')
->get()
->map(fn (ResourceConnection $connection) => $this->serializeResourceConnection($connection))
->all();
}
/**
* @return array<string, mixed>
*/
private function serializeCaddyIngress(V5Server $server, int $index = 0): array
{
return [
'id' => (string) $server->id,
'name' => $server->name,
'host' => $server->host,
'status' => $server->caddyIngressStatus(),
'canvasX' => $server->canvas_x ?? -self::CANVAS_CARD_WIDTH - self::CANVAS_CARD_GAP,
'canvasY' => $server->canvas_y ?? $index * (self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP),
];
}
/**
* @return array<string, mixed>
*/
private function serializeResourceConnection(ResourceConnection $connection): array
{
return [
'id' => (string) $connection->id,
'applicationIds' => [
(string) $connection->resource_one_id,
(string) $connection->resource_two_id,
],
'fromApplicationId' => (string) $connection->resource_one_id,
'toApplicationId' => (string) $connection->resource_two_id,
'portsByDirection' => $connection->rules
->groupBy(fn ($rule) => "{$rule->source_resource_id}->{$rule->target_resource_id}")
->map(fn (Collection $rules) => $rules
->sortBy('port')
->pluck('port')
->map(fn ($port) => (string) $port)
->values()
->all())
->all(),
];
}
/**
* @param array{type: string, id: int} $resource
*/
private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model
{
return match ($resource['type']) {
'application' => V5Application::query()
->where('team_id', $team->id)
->where('project_id', $project->id)
->where('environment_id', $environment->id)
->whereKey($resource['id'])
->firstOrFail(),
};
}
private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string
{
return collect([
$this->resourceIdentity($resourceOne),
$this->resourceIdentity($resourceTwo),
])->sort()->implode('|');
}
private function resourceIdentity(Model $resource): string
{
return $resource->getMorphClass().':'.$resource->getKey();
}
private function connectionHasResourceId(ResourceConnection $connection, mixed $resourceId): bool
{
return in_array((int) $resourceId, [
(int) $connection->resource_one_id,
(int) $connection->resource_two_id,
], true);
}
private function resourceTypeForConnectionId(ResourceConnection $connection, int $resourceId): string
{
return (int) $connection->resource_one_id === $resourceId
? $connection->resource_one_type
: $connection->resource_two_type;
}
/**
* @param array{uuid: string} $selectedProject
* @param array{uuid: string} $selectedEnvironment
* @return Builder<V5Application>
*/
private function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder
{
return V5Application::query()
->where('team_id', $currentTeam->id)
->whereHas('project', fn (Builder $query) => $query
->where('team_id', $currentTeam->id)
->where('uuid', $selectedProject['uuid']))
->whereHas('environment', fn (Builder $query) => $query
->where('uuid', $selectedEnvironment['uuid']));
}
/**
* @return array{canvas_x: int, canvas_y: int}
*/
private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array
{
$existingApplications = V5Application::query()
->where('team_id', $currentTeam->id)
->where('project_id', $project->id)
->where('environment_id', $environment->id)
->get(['canvas_x', 'canvas_y']);
$horizontalStep = self::CANVAS_CARD_WIDTH + self::CANVAS_CARD_GAP;
$verticalStep = self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP;
for ($row = 0; $row < 100; $row++) {
for ($column = 0; $column < 100; $column++) {
$candidate = [
'canvas_x' => $column * $horizontalStep,
'canvas_y' => $row * $verticalStep,
];
if (! $this->canvasPositionCollides($candidate, $existingApplications)) {
return $candidate;
}
}
}
return [
'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep,
'canvas_y' => 0,
];
}
/**
* @param array{canvas_x: int, canvas_y: int} $candidate
* @param Collection<int, V5Application> $existingApplications
*/
private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool
{
return $existingApplications->contains(function (V5Application $application) use ($candidate) {
return abs($candidate['canvas_x'] - $application->canvas_x) < self::CANVAS_CARD_WIDTH + self::CANVAS_CARD_GAP
&& abs($candidate['canvas_y'] - $application->canvas_y) < self::CANVAS_CARD_HEIGHT + self::CANVAS_CARD_GAP;
});
}
/**
* @return array<string, mixed>
*/
private function serializeApplication(V5Application $application): array
{
$application->loadMissing('server');
return [
'id' => (string) $application->id,
'name' => $application->name,
'image' => $application->image,
'containerName' => $application->container_name,
'status' => $application->status,
'statusMessage' => $application->status_message,
'runtimeContainerId' => $application->runtime_container_id,
'serverName' => $application->server?->name,
'meshNamespace' => $application->mesh_namespace,
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
'canvasX' => $application->canvas_x,
'canvasY' => $application->canvas_y,
];
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -754,6 +1426,36 @@ class DashboardController extends Controller
->all();
}
/**
* @return array<int, string>
*/
private function serverCapabilities(bool $builderEnabled, bool $ingressEnabled): array
{
return collect(['coold'])
->when($builderEnabled, fn ($capabilities) => $capabilities->push('builder'))
->when($ingressEnabled, fn ($capabilities) => $capabilities->push('ingress'))
->unique()
->values()
->all();
}
private function reconcileCaddyIngress(V5Server $server, bool $wasIngress, bool $isIngress): void
{
if ($server->status !== 'installed' || ! $server->privateKey instanceof PrivateKey) {
return;
}
if (! $wasIngress && $isIngress) {
StartCaddyIngress::run($server);
return;
}
if ($wasIngress && ! $isIngress) {
StopCaddyIngress::run($server);
}
}
/**
* @return array<string, mixed>
*/
@@ -793,6 +1495,7 @@ class DashboardController extends Controller
'builderEnabled' => $server->builder_enabled,
'builderCapacity' => $server->builder_capacity,
'builderCpuQuota' => $server->builder_cpu_quota,
'ingressEnabled' => $server->isIngress(),
'uuid' => $server->uuid,
'nodeAddress' => $server->node_address,
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
+10
View File
@@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Events\V5ClusterUpdated;
use App\Models\PrivateKey;
use App\Models\V5\Cluster as V5Cluster;
@@ -119,6 +120,7 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
$capabilities = collect($server->capabilities ?? [])
->push('coold')
->when($server->builder_enabled, fn ($capabilities) => $capabilities->push('builder'))
->when($server->isIngress(), fn ($capabilities) => $capabilities->push('ingress'))
->unique()
->values()
->all();
@@ -131,6 +133,10 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
$this->broadcastClusterUpdated($server);
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation);
if ($server->isIngress()) {
StartCaddyIngress::run($server->fresh('privateKey'));
}
} catch (\Throwable $e) {
$this->markFailed($server, $action, $e->getMessage());
} finally {
@@ -343,6 +349,10 @@ class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldQueue
$server->update($updates);
$this->broadcastClusterUpdated($server);
if ($server->isIngress()) {
StartCaddyIngress::run($server->fresh('privateKey'));
}
}
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation): void
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Models\V5;
use App\Events\V5CanvasResourceUpdated;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Application extends V5Model
{
protected $table = 'v5_applications';
protected $fillable = [
'team_id',
'project_id',
'environment_id',
'server_id',
'created_by_user_id',
'name',
'image',
'container_name',
'status',
'status_message',
'runtime_container_id',
'mesh_namespace',
'canvas_x',
'canvas_y',
];
protected $attributes = [
'status' => 'creating',
'mesh_namespace' => 'default',
'canvas_x' => 0,
'canvas_y' => 0,
];
protected static function booted(): void
{
static::updated(function (self $application): void {
if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) {
V5CanvasResourceUpdated::dispatch($application->team_id, $application->id);
}
});
}
protected function casts(): array
{
return [
'canvas_x' => 'integer',
'canvas_y' => 'integer',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContainerStatus extends V5Model
{
protected $table = 'v5_container_statuses';
protected $fillable = [
'team_id',
'server_id',
'container_id',
'container_name',
'image',
'status',
'status_message',
'last_seen_at',
];
protected function casts(): array
{
return [
'last_seen_at' => 'datetime',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models\V5;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ResourceConnection extends V5Model
{
protected $table = 'v5_resource_connections';
protected $fillable = [
'team_id',
'project_id',
'environment_id',
'resource_one_type',
'resource_one_id',
'resource_two_type',
'resource_two_id',
'resource_pair_key',
'created_by_user_id',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function resourceOne(): MorphTo
{
return $this->morphTo('resource_one');
}
public function resourceTwo(): MorphTo
{
return $this->morphTo('resource_two');
}
public function rules(): HasMany
{
return $this->hasMany(ResourceConnectionRule::class, 'connection_id');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models\V5;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ResourceConnectionRule extends V5Model
{
protected $table = 'v5_resource_connection_rules';
protected $fillable = [
'connection_id',
'source_resource_type',
'source_resource_id',
'target_resource_type',
'target_resource_id',
'protocol',
'port',
];
protected $attributes = [
'protocol' => 'tcp',
];
protected function casts(): array
{
return [
'port' => 'integer',
];
}
public function connection(): BelongsTo
{
return $this->belongsTo(ResourceConnection::class, 'connection_id');
}
public function sourceResource(): MorphTo
{
return $this->morphTo('source_resource');
}
public function targetResource(): MorphTo
{
return $this->morphTo('target_resource');
}
}
+66
View File
@@ -2,6 +2,8 @@
namespace App\Models\V5;
use App\Events\V5CanvasResourceUpdated;
use App\Events\V5ClusterUpdated;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
@@ -22,6 +24,7 @@ class Server extends V5Model
'ssh_user',
'ssh_port',
'status',
'caddy_ingress_status',
'capabilities',
'builder_enabled',
'builder_capacity',
@@ -32,6 +35,8 @@ class Server extends V5Model
'wireguard_management_ip',
'wireguard_public_key',
'container_subnets',
'canvas_x',
'canvas_y',
'last_bootstrapped_at',
'last_bootstrap_action',
'last_bootstrap_status',
@@ -42,18 +47,79 @@ class Server extends V5Model
'last_status_checked_at',
];
protected static function booted(): void
{
static::updated(function (self $server): void {
if (! $server->wasChanged('status') && ! $server->wasChanged('caddy_ingress_status')) {
return;
}
if ($server->wasChanged('status') && $server->cluster_id !== null) {
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
}
if ($server->isIngress()) {
V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id);
}
});
}
protected function casts(): array
{
return [
'capabilities' => 'array',
'builder_enabled' => 'boolean',
'container_subnets' => 'array',
'canvas_x' => 'integer',
'canvas_y' => 'integer',
'last_bootstrapped_at' => 'datetime',
'last_bootstrap_ran_at' => 'datetime',
'last_status_checked_at' => 'datetime',
];
}
public function hasCapability(string $capability): bool
{
return in_array($capability, $this->capabilities ?? [], true);
}
/**
* @return array<int, string>
*/
public function withCapability(string $capability): array
{
return collect($this->capabilities ?? [])
->push($capability)
->unique()
->values()
->all();
}
/**
* @return array<int, string>
*/
public function withoutCapability(string $capability): array
{
return collect($this->capabilities ?? [])
->reject(fn (string $existingCapability) => $existingCapability === $capability)
->values()
->all();
}
public function isIngress(): bool
{
return $this->hasCapability('ingress');
}
public function caddyIngressStatus(): string
{
if ($this->caddy_ingress_status !== null) {
return $this->caddy_ingress_status;
}
return $this->status === 'installed' ? 'running' : 'unknown';
}
public function cluster(): BelongsTo
{
return $this->belongsTo(Cluster::class);
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\Flux;
use Illuminate\Support\Str;
use RuntimeException;
class FluxClient
{
/**
* @return array<int, array{id?: string, name?: string, image?: string, state?: string, networks?: array<int, string>}>
*/
public function listContainers(string $hostId): array
{
$payload = $this->dispatch($hostId, [
'type' => 'list_containers',
]);
$data = $payload['data'] ?? [];
return is_array($data) ? $data : [];
}
/**
* @param array<string, mixed> $command
* @return array<string, mixed>
*/
private function dispatch(string $hostId, array $command): array
{
$socketPath = config('flux.unix_socket_path');
if (! is_string($socketPath) || $socketPath === '') {
throw new RuntimeException('Flux socket is not configured.');
}
if (! file_exists($socketPath)) {
throw new RuntimeException('Flux socket was not found.');
}
$body = json_encode([
'host_id' => $hostId,
'request_id' => (string) Str::uuid(),
'command' => $command,
], JSON_THROW_ON_ERROR);
$timeout = (float) config('flux.health_timeout_seconds', 1.0);
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout);
if ($stream === false) {
throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
}
stream_set_timeout($stream, (int) ceil($timeout));
fwrite($stream, implode("\r\n", [
'POST /v1/coold/dispatch HTTP/1.1',
'Host: flux',
'Accept: application/json',
'Content-Type: application/json',
'Content-Length: '.strlen($body),
'Connection: close',
'',
$body,
]));
$response = stream_get_contents($stream) ?: '';
fclose($stream);
if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) {
throw new RuntimeException('Flux dispatch did not return HTTP 200.');
}
$responseBody = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : '';
$payload = json_decode($responseBody, true);
if (! is_array($payload)) {
throw new RuntimeException('Flux dispatch returned an invalid response.');
}
if (($payload['status'] ?? null) === 'error') {
$message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.';
throw new RuntimeException($message);
}
return $payload;
}
}
+1
View File
@@ -5,4 +5,5 @@ return [
'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')),
'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')),
'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0),
'laravel_api_token' => env('COOLIFY_FLUX_LARAVEL_API_TOKEN'),
];
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_applications', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
$table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete();
$table->foreignId('server_id')->nullable()->constrained('v5_servers')->nullOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->string('image');
$table->string('container_name')->unique();
$table->string('status')->default('creating');
$table->text('status_message')->nullable();
$table->string('runtime_container_id')->nullable();
$table->string('mesh_namespace')->default('default');
$table->integer('canvas_x')->default(0);
$table->integer('canvas_y')->default(0);
$table->timestamps();
$table->index(['team_id', 'status']);
$table->index(['team_id', 'project_id', 'environment_id']);
$table->index(['team_id', 'server_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_applications');
}
};
@@ -0,0 +1,29 @@
<?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->integer('canvas_x')->nullable()->after('container_subnets');
$table->integer('canvas_y')->nullable()->after('canvas_x');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn(['canvas_x', 'canvas_y']);
});
}
};
@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_resource_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
$table->foreignId('environment_id')->constrained('environments')->cascadeOnDelete();
$table->morphs('resource_one');
$table->morphs('resource_two');
$table->string('resource_pair_key');
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['team_id', 'resource_pair_key']);
$table->index(['team_id', 'project_id', 'environment_id']);
});
Schema::create('v5_resource_connection_rules', function (Blueprint $table) {
$table->id();
$table->foreignId('connection_id')->constrained('v5_resource_connections')->cascadeOnDelete();
$table->morphs('source_resource');
$table->morphs('target_resource');
$table->string('protocol')->default('tcp');
$table->unsignedSmallInteger('port');
$table->timestamps();
$table->unique([
'connection_id',
'source_resource_type',
'source_resource_id',
'target_resource_type',
'target_resource_id',
'protocol',
'port',
], 'v5_resource_connection_rules_unique_direction_port');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_resource_connection_rules');
Schema::dropIfExists('v5_resource_connections');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
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('caddy_ingress_status')->nullable()->after('status');
});
DB::table('v5_servers')
->where('status', 'installed')
->where(function ($query) {
$query
->where('capabilities', 'like', '%"ingress"%')
->orWhere('capabilities', 'like', '%ingress%');
})
->update(['caddy_ingress_status' => 'running']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('v5_servers', function (Blueprint $table) {
$table->dropColumn('caddy_ingress_status');
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_container_statuses', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('server_id')->constrained('v5_servers')->cascadeOnDelete();
$table->string('container_id');
$table->string('container_name')->nullable();
$table->string('image')->nullable();
$table->string('status')->default('unknown');
$table->text('status_message')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->unique(['server_id', 'container_id']);
$table->index(['team_id', 'server_id']);
$table->index(['team_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_container_statuses');
}
};
+42
View File
@@ -1363,6 +1363,7 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"ssh_user" TEXT NOT NULL,
"ssh_port" INTEGER DEFAULT '22' NOT NULL,
"status" TEXT DEFAULT 'installed' NOT NULL,
"caddy_ingress_status" TEXT,
"capabilities" TEXT,
"builder_enabled" INTEGER DEFAULT false NOT NULL,
"builder_capacity" INTEGER DEFAULT '0' NOT NULL,
@@ -1373,6 +1374,8 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"wireguard_management_ip" TEXT,
"wireguard_public_key" TEXT,
"container_subnets" JSON,
"canvas_x" INTEGER,
"canvas_y" INTEGER,
"last_bootstrapped_at" TEXT,
"last_bootstrap_action" TEXT,
"last_bootstrap_status" TEXT,
@@ -1385,6 +1388,40 @@ CREATE TABLE IF NOT EXISTS "v5_servers" (
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_container_statuses" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"server_id" INTEGER NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT,
"image" TEXT,
"status" TEXT DEFAULT 'unknown' NOT NULL,
"status_message" TEXT,
"last_seen_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_applications" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL,
"environment_id" INTEGER NOT NULL,
"server_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"image" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"status" TEXT DEFAULT 'creating' NOT NULL,
"status_message" TEXT,
"runtime_container_id" TEXT,
"mesh_namespace" TEXT DEFAULT 'default' NOT NULL,
"canvas_x" INTEGER DEFAULT '0' NOT NULL,
"canvas_y" INTEGER DEFAULT '0' NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "webhook_notification_settings" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
@@ -1499,6 +1536,7 @@ CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_cha
CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag);
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_applications_container_name_unique" ON "v5_applications" (container_name);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid);
CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id);
@@ -1819,3 +1857,7 @@ 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_130649_v5_create_clusters_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_141231_add_canvas_position_to_v5_servers_table', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table', 320);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (321, '2026_06_19_182231_create_container_statuses_table', 321);
+18 -33
View File
@@ -2,11 +2,10 @@
namespace Database\Seeders;
use App\Actions\V5\Server\SyncDevLimaServers;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Cluster;
use App\Models\V5\Server;
use Illuminate\Database\Seeder;
class V5DevLimaSeeder extends Seeder
@@ -25,14 +24,6 @@ class V5DevLimaSeeder extends Seeder
return;
}
$cluster = Cluster::query()->updateOrCreate([
'team_id' => $team->id,
'name' => self::CLUSTER_NAME,
], [
'created_by_user_id' => $user->id,
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
]);
$privateKey = PrivateKey::query()
->where('team_id', $team->id)
->where('is_git_related', false)
@@ -40,34 +31,26 @@ class V5DevLimaSeeder extends Seeder
->first();
$builderCapacity = max(0, (int) config('coold.dev_builder_capacity', 2));
$builderEnabled = $builderCapacity > 0;
$capabilities = $builderEnabled ? ['coold', 'builder'] : ['coold'];
$sshUser = (string) config('coold.dev_ssh_user', get_current_user());
foreach ($this->servers() as $server) {
Server::query()->updateOrCreate([
'team_id' => $team->id,
'host' => $server['host'],
'ssh_port' => $server['ssh_port'],
], [
'cluster_id' => $cluster->id,
'created_by_user_id' => $user->id,
'private_key_id' => $privateKey?->id,
'name' => $server['name'],
$servers = collect($this->servers())
->map(fn (array $server): array => [
...$server,
'ssh_user' => $sshUser,
'status' => 'installed',
'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(),
]);
}
])
->all();
SyncDevLimaServers::run(
team: $team,
user: $user,
privateKey: $privateKey,
clusterName: self::CLUSTER_NAME,
builderCapacity: $builderCapacity,
servers: $servers,
);
}
/**
* @return array<int, array{name: string, host: string, ssh_port: int, wireguard_listen_port_override: int, wireguard_endpoint_override: string}>
* @return array<int, array{name: string, host: string, ssh_port: int, wireguard_management_ip: string, wireguard_listen_port_override: int, wireguard_endpoint_override: string}>
*/
private function servers(): array
{
@@ -76,6 +59,7 @@ class V5DevLimaSeeder extends Seeder
'name' => 'coold-dev',
'host' => 'host.docker.internal',
'ssh_port' => 60001,
'wireguard_management_ip' => '100.64.0.1',
'wireguard_listen_port_override' => 51821,
'wireguard_endpoint_override' => 'host.lima.internal:51821',
],
@@ -83,6 +67,7 @@ class V5DevLimaSeeder extends Seeder
'name' => 'coold-dev-2',
'host' => 'host.docker.internal',
'ssh_port' => 60002,
'wireguard_management_ip' => '100.64.0.2',
'wireguard_listen_port_override' => 51822,
'wireguard_endpoint_override' => 'host.lima.internal:51822',
],
@@ -20,7 +20,11 @@ export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/cool
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
export COOLIFY_FLUX_LARAVEL_API_URL="${COOLIFY_FLUX_LARAVEL_API_URL:-http://127.0.0.1:8080}"
if [ -z "${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}" ] && [ -f .env ]; then
COOLIFY_FLUX_LARAVEL_API_TOKEN="$(grep -E '^COOLIFY_FLUX_LARAVEL_API_TOKEN=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
fi
export COOLIFY_FLUX_LARAVEL_API_TOKEN="${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}"
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
@@ -20,7 +20,11 @@ export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/cool
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
export COOLIFY_FLUX_LARAVEL_API_URL="${COOLIFY_FLUX_LARAVEL_API_URL:-http://127.0.0.1:8080}"
if [ -z "${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}" ] && [ -f .env ]; then
COOLIFY_FLUX_LARAVEL_API_TOKEN="$(grep -E '^COOLIFY_FLUX_LARAVEL_API_TOKEN=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
fi
export COOLIFY_FLUX_LARAVEL_API_TOKEN="${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}"
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
+1
View File
@@ -840,6 +840,7 @@ update_env_var() {
update_env_var "APP_ID" "$(openssl rand -hex 16)"
update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)"
update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"
# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist
update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)"
update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)"
+1
View File
@@ -128,6 +128,7 @@ update_env_var() {
}
log "Checking environment variables..."
update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
+2 -2
View File
@@ -151,7 +151,7 @@
html,
body,
#v5-app {
min-height: 100%;
min-height: 100dvh;
}
html {
@@ -168,7 +168,7 @@
body {
@apply bg-background text-foreground;
min-height: 100vh;
min-height: 100dvh;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
+46 -12
View File
@@ -187,6 +187,7 @@ export default function Clusters({
const [selectedPrivateKeyId, setSelectedPrivateKeyId] = useState('');
const [serverNodeAddress, setServerNodeAddress] = useState('');
const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true);
const [serverIngressEnabled, setServerIngressEnabled] = useState(false);
const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2');
const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState('');
@@ -194,6 +195,7 @@ export default function Clusters({
const [serverErrors, setServerErrors] = useState<ServerFormErrors>({});
const [editingServer, setEditingServer] = useState<V5Server | null>(null);
const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true);
const [editServerIngressEnabled, setEditServerIngressEnabled] = useState(false);
const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2');
const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
const [editServerErrors, setEditServerErrors] = useState<ServerFormErrors>({});
@@ -419,6 +421,7 @@ export default function Clusters({
private_key_id: selectedPrivateKeyId === '' ? null : Number(selectedPrivateKeyId),
node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress,
builder_enabled: serverBuilderEnabled,
ingress_enabled: serverIngressEnabled,
builder_capacity: Number(serverBuilderCapacity),
builder_cpu_quota: serverBuilderCpuQuota,
wireguard_listen_port_override:
@@ -476,6 +479,7 @@ export default function Clusters({
},
body: JSON.stringify({
builder_enabled: editServerBuilderEnabled,
ingress_enabled: editServerIngressEnabled,
builder_capacity: Number(editServerBuilderCapacity),
builder_cpu_quota: editServerBuilderCpuQuota,
}),
@@ -624,6 +628,7 @@ export default function Clusters({
function openEditServerDialog(server: V5Server): void {
setEditingServer(server);
setEditServerBuilderEnabled(server.builderEnabled);
setEditServerIngressEnabled(server.ingressEnabled);
setEditServerBuilderCapacity(String(server.builderCapacity));
setEditServerBuilderCpuQuota(server.builderCpuQuota);
setEditServerErrors({});
@@ -727,6 +732,7 @@ export default function Clusters({
function resetEditServerForm(): void {
setEditingServer(null);
setEditServerBuilderEnabled(true);
setEditServerIngressEnabled(false);
setEditServerBuilderCapacity('2');
setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota);
setEditServerErrors({});
@@ -747,12 +753,12 @@ export default function Clusters({
return (
<article key={server.id} className="rounded-lg border border-border bg-background p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h4 className="break-words text-sm font-semibold text-foreground">{server.name}</h4>
<p className="mt-1 break-all text-xs text-muted-foreground">{server.host}</p>
</div>
<div className="flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
<div className="flex shrink-0 items-center justify-end gap-2 sm:flex-wrap">
{!isServerInitialized ? (
<div role="group" aria-label="Server initialization" className="inline-flex">
<span className="inline-flex h-7 items-center rounded-l-md border border-r-0 border-destructive/30 bg-destructive/10 px-2 text-xs font-medium text-destructive">
@@ -842,6 +848,12 @@ export default function Clusters({
<dd className="mt-1 break-words font-medium text-foreground">{server.builderCpuQuota}</dd>
</div>
) : null}
<div>
<dt className="text-muted-foreground">Caddy ingress</dt>
<dd className="mt-1 break-words font-medium text-foreground">
{server.ingressEnabled ? 'Enabled' : 'Disabled'}
</dd>
</div>
<div>
<dt className="text-muted-foreground">WireGuard IP</dt>
<dd className="mt-1 break-words font-medium text-foreground">
@@ -1611,6 +1623,17 @@ export default function Clusters({
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={serverIngressEnabled}
onChange={(event) =>
setServerIngressEnabled(event.target.checked)
}
/>
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
</Field>
<Field>
<FieldLabel>WireGuard listen override</FieldLabel>
<Input
@@ -1678,20 +1701,31 @@ export default function Clusters({
<DialogHeader>
<DialogTitle>Edit server</DialogTitle>
<DialogDescription>
Update builder scheduling limits for {editingServer?.name ?? 'this server'}.
Update builder scheduling limits and Caddy ingress for {editingServer?.name ?? 'this server'}.
Networking and bootstrap settings stay locked after creation.
</DialogDescription>
</DialogHeader>
<form className="mt-5 flex flex-col gap-4" onSubmit={updateServer}>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerBuilderEnabled}
onChange={(event) => setEditServerBuilderEnabled(event.target.checked)}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerBuilderEnabled}
onChange={(event) => setEditServerBuilderEnabled(event.target.checked)}
/>
<FieldLabel>Enable builder on this server</FieldLabel>
</Field>
<Field className="flex-row items-center gap-2">
<input
type="checkbox"
checked={editServerIngressEnabled}
onChange={(event) => setEditServerIngressEnabled(event.target.checked)}
/>
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -1,5 +1,5 @@
import { Link, usePage } from '@inertiajs/react';
import { useMemo, useState } from 'react';
import { Link, router, usePage } from '@inertiajs/react';
import { useEffect, useMemo, useState } from 'react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
@@ -7,8 +7,8 @@ import { csrfToken } from '@/lib/csrf';
import { cn } from '@/lib/utils';
import type { SelectItemOption, V5DashboardProps, V5Project } from '@/types';
function persistSelection(projectUuid: string, environmentUuid: string): void {
void fetch('/v5/selection', {
async function persistSelection(projectUuid: string, environmentUuid: string): Promise<void> {
await fetch('/v5/selection', {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -23,6 +23,12 @@ function persistSelection(projectUuid: string, environmentUuid: string): void {
});
}
function refreshCurrentPageSelection(): void {
router.reload({
only: ['applications', 'selectedProjectUuid', 'selectedEnvironmentUuid'],
});
}
type AppNavbarProps = V5DashboardProps;
export function AppNavbar({
@@ -44,6 +50,14 @@ export function AppNavbar({
[environmentUuid, firstEnvironment, selectedProject],
);
useEffect(() => {
setProjectUuid(selectedProjectUuid ?? firstProject?.uuid ?? '');
}, [firstProject?.uuid, selectedProjectUuid]);
useEffect(() => {
setEnvironmentUuid(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
}, [firstEnvironment?.uuid, selectedEnvironmentUuid]);
function selectProject(nextProjectUuid: string | null): void {
if (nextProjectUuid === null) {
return;
@@ -54,7 +68,7 @@ export function AppNavbar({
setProjectUuid(nextProjectUuid);
setEnvironmentUuid(nextEnvironmentUuid);
persistSelection(nextProjectUuid, nextEnvironmentUuid);
void persistSelection(nextProjectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);
}
function selectEnvironment(nextEnvironmentUuid: string | null): void {
@@ -63,7 +77,7 @@ export function AppNavbar({
}
setEnvironmentUuid(nextEnvironmentUuid);
persistSelection(projectUuid, nextEnvironmentUuid);
void persistSelection(projectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);
}
const projectItems: SelectItemOption[] = projects.map((project) => ({
+80
View File
@@ -0,0 +1,80 @@
export type CanvasNodeBounds = {
id: string;
x: number;
y: number;
width: number;
height: number;
};
export type CanvasNodePosition = {
x: number;
y: number;
};
export function resolveCanvasNodeLayout(nodes: CanvasNodeBounds[], gap: number): CanvasNodeBounds[] {
return nodes.reduce<CanvasNodeBounds[]>((settledNodes, node) => {
const position = resolveCanvasNodePosition(node, settledNodes, gap);
return [...settledNodes, { ...node, ...position }];
}, []);
}
export function resolveCanvasNodePosition(
node: CanvasNodeBounds,
nodes: CanvasNodeBounds[],
gap: number,
): CanvasNodePosition {
const otherNodes = nodes.filter((otherNode) => otherNode.id !== node.id);
let position = { x: node.x, y: node.y };
for (let attempt = 0; attempt < 50; attempt += 1) {
const collision = otherNodes.find((otherNode) => canvasNodesOverlap({ ...node, ...position }, otherNode, gap));
if (!collision) {
return position;
}
position = closestCanvasNodePosition(node, collision, gap, otherNodes, position);
}
return position;
}
function closestCanvasNodePosition(
node: CanvasNodeBounds,
collision: CanvasNodeBounds,
gap: number,
otherNodes: CanvasNodeBounds[],
targetPosition: CanvasNodePosition,
): CanvasNodePosition {
const candidates = [
{ x: targetPosition.x, y: collision.y - node.height - gap },
{ x: collision.x + collision.width + gap, y: targetPosition.y },
{ x: targetPosition.x, y: collision.y + collision.height + gap },
{ x: collision.x - node.width - gap, y: targetPosition.y },
].sort((firstCandidate, secondCandidate) => {
const firstDistance = canvasDistance(firstCandidate, targetPosition);
const secondDistance = canvasDistance(secondCandidate, targetPosition);
return firstDistance - secondDistance;
});
return (
candidates.find((candidate) =>
otherNodes.every((otherNode) => !canvasNodesOverlap({ ...node, ...candidate }, otherNode, gap)),
) ?? candidates[0]
);
}
function canvasNodesOverlap(node: CanvasNodeBounds, otherNode: CanvasNodeBounds, gap: number): boolean {
return (
node.x < otherNode.x + otherNode.width + gap &&
node.x + node.width + gap > otherNode.x &&
node.y < otherNode.y + otherNode.height + gap &&
node.y + node.height + gap > otherNode.y
);
}
function canvasDistance(firstPosition: CanvasNodePosition, secondPosition: CanvasNodePosition): number {
return Math.hypot(firstPosition.x - secondPosition.x, firstPosition.y - secondPosition.y);
}
+45
View File
@@ -14,6 +14,7 @@ export type V5Server = {
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
ingressEnabled: boolean;
uuid: string | null;
nodeAddress: string | null;
wireguardListenPortOverride: number | null;
@@ -73,11 +74,55 @@ export type V5PrivateKey = {
name: string;
};
export type V5NginxServer = {
id: string;
name: string;
host: string;
status: string;
};
export type V5Application = {
id: string;
name: string;
image: string;
containerName: string;
status: 'creating' | 'running' | 'failed' | string;
statusMessage: string | null;
runtimeContainerId: string | null;
serverName: string | null;
meshNamespace: string;
meshFqdn: string;
canvasX: number;
canvasY: number;
};
export type V5CaddyIngress = {
id: string;
name: string;
host: string;
status: string;
canvasX: number;
canvasY: number;
};
export type V5ResourceConnection = {
id: string;
applicationIds: [string, string];
fromApplicationId: string;
toApplicationId: string;
portsByDirection: Record<string, string[]>;
};
export type V5DashboardProps = {
flux: FluxStatus | null;
currentTeam?: {
id: number;
} | null;
applications?: V5Application[];
caddyIngresses?: V5CaddyIngress[];
resourceConnections?: V5ResourceConnection[];
nginxServers?: V5NginxServer[];
clusters?: V5Cluster[];
privateKeys?: V5PrivateKey[];
projects?: V5Project[];
+2
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@@ -208,6 +209,7 @@ Route::group([
Route::group([
'prefix' => 'v1',
], function () {
Route::post('/internal/flux/resource-status', FluxResourceStatusController::class);
Route::post('/sentinel/push', [SentinelController::class, 'push']);
});
+8
View File
@@ -8,6 +8,14 @@ Route::middleware('v5.authenticated')->group(function () {
Route::get('/realtime-test', [DashboardController::class, 'realtimeTest'])->name('realtime-test');
Route::post('/realtime-test', [DashboardController::class, 'broadcastRealtimeTest'])->name('realtime-test.broadcast');
Route::post('/selection', [DashboardController::class, 'updateSelection'])->name('selection.update');
Route::post('/applications/nginx', [DashboardController::class, 'storeNginxApplication'])->name('applications.nginx');
Route::post('/applications/refresh', [DashboardController::class, 'refreshApplications'])->name('applications.refresh');
Route::delete('/applications/{application}', [DashboardController::class, 'destroyApplication'])->name('applications.destroy');
Route::patch('/applications/{application}/position', [DashboardController::class, 'updateApplicationPosition'])->name('applications.position');
Route::patch('/caddy-ingresses/{server}/position', [DashboardController::class, 'updateCaddyIngressPosition'])->name('caddy-ingresses.position');
Route::post('/resource-connections', [DashboardController::class, 'storeResourceConnection'])->name('resource-connections.store');
Route::patch('/resource-connections/{connection}', [DashboardController::class, 'updateResourceConnection'])->name('resource-connections.update');
Route::delete('/resource-connections/{connection}', [DashboardController::class, 'destroyResourceConnection'])->name('resource-connections.destroy');
Route::get('/clusters', [DashboardController::class, 'clustersIndex'])->name('clusters.index');
Route::get('/clusters/{cluster}', [DashboardController::class, 'showCluster'])->name('clusters.show');
Route::post('/clusters', [DashboardController::class, 'storeCluster'])->name('clusters.store');
+21
View File
@@ -244,6 +244,23 @@ ensure_podman_networks() {
fi
}
ensure_mesh_dns_anchor() {
lima_shell sudo podman run -d --replace \
--name coolify-v5-mesh-dns-anchor \
--network coolify-default-mesh \
docker.io/library/alpine:3.20 \
sleep infinity >/dev/null
}
configure_system_resolved() {
lima_shell sudo rm -f /etc/systemd/resolved.conf.d/coolify-internal.conf
lima_shell sudo systemctl restart systemd-resolved.service
lima_shell sudo resolvectl dns podman1 "$CONTAINER_GATEWAY"
lima_shell sudo resolvectl domain podman1 '~coolify.internal'
lima_shell sudo resolvectl default-route podman1 false
}
write_runtime_config() {
local gossip_addr="127.0.0.1:8787"
local bootstrap=""
@@ -294,6 +311,8 @@ run_foreground() {
stop_agent_processes
write_runtime_config
ensure_podman_networks
configure_system_resolved
ensure_mesh_dns_anchor
install_mesh_firewall
(cd /tmp && limactl shell "$INSTANCE" -- sudo \
@@ -395,6 +414,8 @@ start_agent() {
stop_agent_processes
write_runtime_config
ensure_podman_networks
configure_system_resolved
ensure_mesh_dns_anchor
install_mesh_firewall
lima_shell sudo sh -c 'if [ ! -s /etc/coolify/api-token ]; then openssl rand -hex 32 > /etc/coolify/api-token.tmp && chmod 600 /etc/coolify/api-token.tmp && mv /etc/coolify/api-token.tmp /etc/coolify/api-token; fi'
+1 -1
View File
@@ -529,7 +529,7 @@ sync_v5_dev_lima_servers() {
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}")
server_args+=(--server="${instance}|host.docker.internal|${ssh_user}|${ssh_port}|$(coold_vm_wg_ip "$index")")
done
echo "==> Running pending migrations before syncing v5 dev Lima state..."
+1
View File
@@ -840,6 +840,7 @@ update_env_var() {
update_env_var "APP_ID" "$(openssl rand -hex 16)"
update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)"
update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"
# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist
update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)"
update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)"
+1
View File
@@ -128,6 +128,7 @@ update_env_var() {
}
log "Checking environment variables..."
update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
+24
View File
@@ -51,3 +51,27 @@ function runContainerRoleHelper(string $roles, string $serviceRole, ?string $wor
->env(['COOLIFY_CONTAINER_ROLE' => $roles])
->run($command);
}
it('does not register a separate v5 flux status listener service', function () {
foreach (['development', 'production'] as $environment) {
$base = base_path("docker/{$environment}/etc/s6-overlay/s6-rc.d");
expect(file_exists("{$base}/v5-flux-status-listener"))->toBeFalse()
->and(file_exists("{$base}/user/contents.d/v5-flux-status-listener"))->toBeFalse();
}
});
it('configures flux to publish v5 resource statuses to laravel over local http by default', function () {
foreach (['development', 'production'] as $environment) {
$runScript = file_get_contents(base_path("docker/{$environment}/etc/s6-overlay/s6-rc.d/flux/run"));
expect($runScript)
->toContain('COOLIFY_FLUX_LARAVEL_API_URL')
->toContain('http://127.0.0.1:8080')
->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN')
->not->toContain('APP_KEY')
->not->toContain("grep -E '^APP_KEY=' .env")
->not->toContain('COOLIFY_FLUX_REDIS_URL')
->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL');
}
});
@@ -75,7 +75,7 @@ it('seeds bootstrapped Lima VMs into v5 development server state', function () {
->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($script)->toContain('--server="${instance}|host.docker.internal|${ssh_user}|${ssh_port}|$(coold_vm_wg_ip "$index")"')
->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"');
File diff suppressed because it is too large Load Diff
+12
View File
@@ -44,6 +44,18 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('generates a dedicated flux laravel api token during install and upgrade', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)
->toContain('update_env_var "COOLIFY_FLUX_LARAVEL_API_TOKEN" "$(openssl rand -hex 32)"');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
@@ -0,0 +1,149 @@
<?php
use App\Actions\V5\Proxy\GenerateCaddyIngressConfiguration;
use App\Actions\V5\Proxy\StartCaddyIngress;
use App\Actions\V5\Proxy\StopCaddyIngress;
use App\Models\PrivateKey;
use App\Models\V5\Server;
use Illuminate\Support\Facades\Process;
use Tests\TestCase;
uses(TestCase::class);
it('generates a caddy ingress compose file with health endpoint', function () {
$configuration = GenerateCaddyIngressConfiguration::run();
expect($configuration['compose'])->toContain('container_name: coolify-v5-caddy')
->and($configuration['compose'])->toContain("image: 'docker.io/library/caddy:2-alpine'")
->and($configuration['compose'])->toContain('80:80')
->and($configuration['compose'])->toContain('443:443')
->and($configuration['compose'])->toContain('./Caddyfile:/etc/caddy/Caddyfile:ro')
->and($configuration['caddyfile'])->toContain('respond /coolify-health 200')
->and($configuration['caddyfile'])->toContain('respond 404');
});
it('builds caddy ingress install commands with sudo fallback for non-root ssh users', function () {
$configuration = GenerateCaddyIngressConfiguration::run('/tmp/coolify-caddy');
$script = implode("\n", $configuration['commands']);
expect($configuration['commands'])->toHaveCount(6)
->and($script)->toContain('sudo mkdir -p /tmp/coolify-caddy/data /tmp/coolify-caddy/config')
->and($script)->toContain('sudo tee /tmp/coolify-caddy/docker-compose.yml')
->and($script)->toContain('sudo tee /tmp/coolify-caddy/Caddyfile')
->and($script)->toContain('command -v podman')
->and($script)->toContain('command -v docker')
->and(strpos($script, 'command -v podman'))->toBeLessThan(strpos($script, 'command -v docker'))
->and($script)->toContain('coolify-v5-caddy')
->and($script)->toContain('-v /tmp/coolify-caddy/Caddyfile:/etc/caddy/Caddyfile:ro');
});
it('throws when the caddy ingress start command fails', function () {
$privateKey = new PrivateKey([
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
]);
$server = new Server([
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['coold', 'ingress'],
]);
$server->setRelation('privateKey', $privateKey);
Process::fake([
'*' => Process::result(errorOutput: 'mkdir: Permission denied', exitCode: 1),
]);
StartCaddyIngress::run($server);
})->throws(RuntimeException::class, 'Failed to start Caddy ingress: mkdir: Permission denied');
it('starts caddy ingress over ssh for ingress servers', function () {
$privateKey = new PrivateKey([
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
]);
$server = new Server([
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['coold', 'ingress'],
]);
$server->setRelation('privateKey', $privateKey);
Process::fake([
'*' => Process::result(output: ''),
]);
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress started.');
Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return is_string($command)
&& str_contains($command, 'command -v podman')
&& str_contains($command, 'command -v docker')
&& strpos($command, 'command -v podman') < strpos($command, 'command -v docker')
&& str_contains($command, 'coolify-v5-caddy');
});
});
it('prefers podman for every caddy ingress runtime command', function () {
$configuration = GenerateCaddyIngressConfiguration::run('/tmp/coolify-caddy');
$runtimeCommands = collect($configuration['commands'])
->filter(fn (string $command) => str_contains($command, 'command -v podman') && str_contains($command, 'command -v docker'));
expect($runtimeCommands)->toHaveCount(3);
$runtimeCommands->each(function (string $command): void {
expect(strpos($command, 'command -v podman'))->toBeLessThan(strpos($command, 'command -v docker'));
});
});
it('does not start caddy ingress for non-ingress servers', function () {
$server = new Server([
'capabilities' => ['coold'],
]);
Process::fake();
$result = StartCaddyIngress::run($server);
expect($result)->toBe('Server is not an ingress server.');
Process::assertNothingRan();
});
it('stops caddy ingress over ssh', function () {
$privateKey = new PrivateKey([
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
]);
$server = new Server([
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'capabilities' => ['coold'],
]);
$server->setRelation('privateKey', $privateKey);
Process::fake([
'*' => Process::result(output: ''),
]);
$result = StopCaddyIngress::run($server);
expect($result)->toBe('Caddy ingress stopped.');
Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return is_string($command)
&& str_contains($command, 'command -v podman')
&& str_contains($command, 'command -v docker')
&& strpos($command, 'command -v podman') < strpos($command, 'command -v docker')
&& str_contains($command, 'coolify-v5-caddy');
});
});
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition } from '../../../../resources/js/v5/lib/canvas-collision.ts';
test('moves a dragged canvas node to the closest non-overlapping side with a gap', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[{ id: 'existing', x: 0, y: 0, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 110, y: 152 });
});
test('keeps moving until the closest side is clear', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'dragged', x: 110, y: 10, width: 320, height: 136 },
[
{ id: 'left-blocker', x: 0, y: 0, width: 320, height: 136 },
{ id: 'bottom-blocker', x: 110, y: 152, width: 320, height: 136 },
{ id: 'top-blocker', x: 110, y: -152, width: 320, height: 136 },
],
16,
);
assert.deepEqual(settledPosition, { x: -336, y: 10 });
});
test('ignores the dragged node when comparing canvas collisions', () => {
const settledPosition = resolveCanvasNodePosition(
{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 },
[{ id: 'app-1', x: 24, y: 32, width: 320, height: 136 }],
16,
);
assert.deepEqual(settledPosition, { x: 24, y: 32 });
});
test('spreads an overlapping canvas layout in order', () => {
const settledNodes = resolveCanvasNodeLayout(
[
{ id: 'first', x: 0, y: 0, width: 320, height: 136 },
{ id: 'second', x: 110, y: 10, width: 320, height: 136 },
],
16,
);
assert.deepEqual(
settledNodes.map((node) => ({ id: node.id, x: node.x, y: node.y })),
[
{ id: 'first', x: 0, y: 0 },
{ id: 'second', x: 110, y: 152 },
],
);
});
@@ -0,0 +1,32 @@
<?php
use App\Actions\V5\Application\DeployNginxApplication;
use App\Models\V5\Application;
use Tests\TestCase;
uses(TestCase::class);
it('verifies nginx is running before marking the application running', function () {
$application = new Application([
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-test',
'status' => 'creating',
]);
$action = new DeployNginxApplication;
$method = new ReflectionMethod($action, 'remoteCommand');
$method->setAccessible(true);
$remoteCommand = $method->invoke($action, $application);
expect($remoteCommand)
->toContain('if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi')
->toContain("--network 'coolify-default-mesh'")
->toContain("--network-alias 'coolify-v5-nginx-test'")
->toContain('$podman inspect')
->not->toContain('docker run')
->not->toContain('docker inspect')
->toContain('.State.Running')
->toContain('Container did not stay running')
->toContain('exit 1');
});