mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-03 16:16:40 +00:00
feat(v5): add mesh app canvas
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
APP_ID=
|
||||
APP_NAME=Coolify
|
||||
APP_KEY=
|
||||
COOLIFY_FLUX_LARAVEL_API_TOKEN=
|
||||
|
||||
DB_USERNAME=coolify
|
||||
DB_PASSWORD=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+38
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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
@@ -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..."
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"');
|
||||
|
||||
+1711
-12
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user