From 86156b6f7ae652070cdc322f46729e9e03a4df1a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:23:16 +0200 Subject: [PATCH] feat(v5): add mesh app canvas --- .env.development.example | 1 + .env.dusk.ci | 1 + .env.production | 1 + .env.testing | 1 + .../V5/Application/DeployNginxApplication.php | 137 ++ .../Application/DestroyNginxApplication.php | 96 + .../V5/Flux/ApplyFluxResourceStatusUpdate.php | 268 +++ .../GenerateCaddyIngressConfiguration.php | 57 + app/Actions/V5/Proxy/StartCaddyIngress.php | 85 + app/Actions/V5/Proxy/StopCaddyIngress.php | 78 + app/Actions/V5/Server/SyncDevLimaServers.php | 81 + app/Console/Commands/V5SyncDevLimaServers.php | 52 +- app/Events/V5CanvasResourceUpdated.php | 90 + .../Internal/FluxResourceStatusController.php | 59 + .../Controllers/V5/DashboardController.php | 719 ++++++- app/Jobs/V5BootstrapServerJob.php | 10 + app/Models/V5/Application.php | 81 + app/Models/V5/ContainerStatus.php | 39 + app/Models/V5/ResourceConnection.php | 63 + app/Models/V5/ResourceConnectionRule.php | 47 + app/Models/V5/Server.php | 66 + app/Services/Flux/FluxClient.php | 87 + config/flux.php | 1 + ...19_140000_v5_create_applications_table.php | 45 + ...dd_canvas_position_to_v5_servers_table.php | 29 + ...0_v5_create_resource_connections_table.php | 58 + ...ddy_ingress_status_to_v5_servers_table.php | 38 + ...182231_create_container_statuses_table.php | 39 + database/schema/testing-schema.sql | 42 + database/seeders/V5DevLimaSeeder.php | 51 +- .../etc/s6-overlay/s6-rc.d/flux/run | 6 +- .../etc/s6-overlay/s6-rc.d/flux/run | 6 +- other/nightly/install.sh | 1 + other/nightly/upgrade.sh | 1 + resources/css/v5/app.css | 4 +- resources/js/v5/Pages/Clusters.tsx | 58 +- resources/js/v5/Pages/Dashboard.tsx | 1467 +++++++++++++- resources/js/v5/components/app-navbar.tsx | 26 +- resources/js/v5/lib/canvas-collision.ts | 80 + resources/js/v5/types.ts | 45 + routes/api.php | 2 + routes/v5.php | 8 + scripts/coold-vm.sh | 21 + scripts/dev.sh | 2 +- scripts/install.sh | 1 + scripts/upgrade.sh | 1 + tests/Feature/ContainerRoleScriptTest.php | 24 + .../DevScriptFirewallDelegationTest.php | 2 +- tests/Feature/V5/DashboardTest.php | 1723 ++++++++++++++++- tests/Unit/UpgradePostgresScriptTest.php | 12 + .../Unit/V5/CaddyIngressConfigurationTest.php | 149 ++ .../V5/JavaScript/canvas-collision.test.ts | 57 + .../V5/NginxApplicationDeploymentTest.php | 32 + 53 files changed, 6037 insertions(+), 113 deletions(-) create mode 100644 app/Actions/V5/Application/DeployNginxApplication.php create mode 100644 app/Actions/V5/Application/DestroyNginxApplication.php create mode 100644 app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php create mode 100644 app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php create mode 100644 app/Actions/V5/Proxy/StartCaddyIngress.php create mode 100644 app/Actions/V5/Proxy/StopCaddyIngress.php create mode 100644 app/Actions/V5/Server/SyncDevLimaServers.php create mode 100644 app/Events/V5CanvasResourceUpdated.php create mode 100644 app/Http/Controllers/Api/Internal/FluxResourceStatusController.php create mode 100644 app/Models/V5/Application.php create mode 100644 app/Models/V5/ContainerStatus.php create mode 100644 app/Models/V5/ResourceConnection.php create mode 100644 app/Models/V5/ResourceConnectionRule.php create mode 100644 app/Services/Flux/FluxClient.php create mode 100644 database/migrations/2026_06_19_140000_v5_create_applications_table.php create mode 100644 database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php create mode 100644 database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php create mode 100644 database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php create mode 100644 database/migrations/2026_06_19_182231_create_container_statuses_table.php create mode 100644 resources/js/v5/lib/canvas-collision.ts create mode 100644 tests/Unit/V5/CaddyIngressConfigurationTest.php create mode 100644 tests/Unit/V5/JavaScript/canvas-collision.test.ts create mode 100644 tests/Unit/V5/NginxApplicationDeploymentTest.php diff --git a/.env.development.example b/.env.development.example index 887f62974..f0665c1be 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/.env.dusk.ci b/.env.dusk.ci index 9660de7b4..913c08120 100644 --- a/.env.dusk.ci +++ b/.env.dusk.ci @@ -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 diff --git a/.env.production b/.env.production index fe3c8370e..2a6b2e741 100644 --- a/.env.production +++ b/.env.production @@ -1,6 +1,7 @@ APP_ID= APP_NAME=Coolify APP_KEY= +COOLIFY_FLUX_LARAVEL_API_TOKEN= DB_USERNAME=coolify DB_PASSWORD= diff --git a/.env.testing b/.env.testing index 2f79f3389..d72330b5d 100644 --- a/.env.testing +++ b/.env.testing @@ -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 diff --git a/app/Actions/V5/Application/DeployNginxApplication.php b/app/Actions/V5/Application/DeployNginxApplication.php new file mode 100644 index 000000000..4dcd550c7 --- /dev/null +++ b/app/Actions/V5/Application/DeployNginxApplication.php @@ -0,0 +1,137 @@ +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; + } +} diff --git a/app/Actions/V5/Application/DestroyNginxApplication.php b/app/Actions/V5/Application/DestroyNginxApplication.php new file mode 100644 index 000000000..1910eb654 --- /dev/null +++ b/app/Actions/V5/Application/DestroyNginxApplication.php @@ -0,0 +1,96 @@ +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; + } +} diff --git a/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php new file mode 100644 index 000000000..d3a078d8a --- /dev/null +++ b/app/Actions/V5/Flux/ApplyFluxResourceStatusUpdate.php @@ -0,0 +1,268 @@ + $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 $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 $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 $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 $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 $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 $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 $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 $payload + */ + private function status(array $payload): ?string + { + $status = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state'); + + return $status === null ? null : strtolower($status); + } + + /** + * @param array $payload + */ + private function statusMessage(array $payload, string $fallback): string + { + return $this->stringValue($payload, 'status_message') + ?? $this->stringValue($payload, 'message') + ?? $fallback; + } + + /** + * @param array $payload + */ + private function stringValue(array $payload, string $key): ?string + { + $value = data_get($payload, $key); + + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @param array $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; + } +} diff --git a/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php new file mode 100644 index 000000000..cb97d4fdf --- /dev/null +++ b/app/Actions/V5/Proxy/GenerateCaddyIngressConfiguration.php @@ -0,0 +1,57 @@ +} + */ + 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", + ], + ]; + } +} diff --git a/app/Actions/V5/Proxy/StartCaddyIngress.php b/app/Actions/V5/Proxy/StartCaddyIngress.php new file mode 100644 index 000000000..aa2d0fd2e --- /dev/null +++ b/app/Actions/V5/Proxy/StartCaddyIngress.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/app/Actions/V5/Proxy/StopCaddyIngress.php b/app/Actions/V5/Proxy/StopCaddyIngress.php new file mode 100644 index 000000000..7c09944a0 --- /dev/null +++ b/app/Actions/V5/Proxy/StopCaddyIngress.php @@ -0,0 +1,78 @@ +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; + } +} diff --git a/app/Actions/V5/Server/SyncDevLimaServers.php b/app/Actions/V5/Server/SyncDevLimaServers.php new file mode 100644 index 000000000..183a29c52 --- /dev/null +++ b/app/Actions/V5/Server/SyncDevLimaServers.php @@ -0,0 +1,81 @@ + $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(); + } +} diff --git a/app/Console/Commands/V5SyncDevLimaServers.php b/app/Console/Commands/V5SyncDevLimaServers.php index d55e3531f..fb9a1dd5d 100644 --- a/app/Console/Commands/V5SyncDevLimaServers.php +++ b/app/Console/Commands/V5SyncDevLimaServers.php @@ -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; diff --git a/app/Events/V5CanvasResourceUpdated.php b/app/Events/V5CanvasResourceUpdated.php new file mode 100644 index 000000000..bb6cce17e --- /dev/null +++ b/app/Events/V5CanvasResourceUpdated.php @@ -0,0 +1,90 @@ +teamId}"), + ]; + } + + public function broadcastAs(): string + { + return 'v5.canvas.resource.updated'; + } + + /** + * @return array{application: array|null, caddyIngress: array|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 + */ + 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 + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php b/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php new file mode 100644 index 000000000..81c0322e1 --- /dev/null +++ b/app/Http/Controllers/Api/Internal/FluxResourceStatusController.php @@ -0,0 +1,59 @@ +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.', + ]); + } +} diff --git a/app/Http/Controllers/V5/DashboardController.php b/app/Http/Controllers/V5/DashboardController.php index 904e1e9d0..eecb6fc6f 100644 --- a/app/Http/Controllers/V5/DashboardController.php +++ b/app/Http/Controllers/V5/DashboardController.php @@ -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 + */ + /** + * @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> + */ + 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> + */ + 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> + */ + 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 + */ + 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 + */ + 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 + */ + 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 $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 + */ + 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> */ @@ -754,6 +1426,36 @@ class DashboardController extends Controller ->all(); } + /** + * @return array + */ + 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 */ @@ -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, diff --git a/app/Jobs/V5BootstrapServerJob.php b/app/Jobs/V5BootstrapServerJob.php index 3773a34fd..33e18acc9 100644 --- a/app/Jobs/V5BootstrapServerJob.php +++ b/app/Jobs/V5BootstrapServerJob.php @@ -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 diff --git a/app/Models/V5/Application.php b/app/Models/V5/Application.php new file mode 100644 index 000000000..fd68638d2 --- /dev/null +++ b/app/Models/V5/Application.php @@ -0,0 +1,81 @@ + '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'); + } +} diff --git a/app/Models/V5/ContainerStatus.php b/app/Models/V5/ContainerStatus.php new file mode 100644 index 000000000..6985ccda8 --- /dev/null +++ b/app/Models/V5/ContainerStatus.php @@ -0,0 +1,39 @@ + 'datetime', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } +} diff --git a/app/Models/V5/ResourceConnection.php b/app/Models/V5/ResourceConnection.php new file mode 100644 index 000000000..12755819d --- /dev/null +++ b/app/Models/V5/ResourceConnection.php @@ -0,0 +1,63 @@ +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'); + } +} diff --git a/app/Models/V5/ResourceConnectionRule.php b/app/Models/V5/ResourceConnectionRule.php new file mode 100644 index 000000000..10fe290ee --- /dev/null +++ b/app/Models/V5/ResourceConnectionRule.php @@ -0,0 +1,47 @@ + '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'); + } +} diff --git a/app/Models/V5/Server.php b/app/Models/V5/Server.php index 7aa13b9de..0e9148aba 100644 --- a/app/Models/V5/Server.php +++ b/app/Models/V5/Server.php @@ -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 + */ + public function withCapability(string $capability): array + { + return collect($this->capabilities ?? []) + ->push($capability) + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + 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); diff --git a/app/Services/Flux/FluxClient.php b/app/Services/Flux/FluxClient.php new file mode 100644 index 000000000..2cd5e28ab --- /dev/null +++ b/app/Services/Flux/FluxClient.php @@ -0,0 +1,87 @@ +}> + */ + public function listContainers(string $hostId): array + { + $payload = $this->dispatch($hostId, [ + 'type' => 'list_containers', + ]); + + $data = $payload['data'] ?? []; + + return is_array($data) ? $data : []; + } + + /** + * @param array $command + * @return array + */ + 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; + } +} diff --git a/config/flux.php b/config/flux.php index dea770e47..cf23d57dc 100644 --- a/config/flux.php +++ b/config/flux.php @@ -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'), ]; diff --git a/database/migrations/2026_06_19_140000_v5_create_applications_table.php b/database/migrations/2026_06_19_140000_v5_create_applications_table.php new file mode 100644 index 000000000..e478f3c1d --- /dev/null +++ b/database/migrations/2026_06_19_140000_v5_create_applications_table.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php b/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php new file mode 100644 index 000000000..3ccb15c94 --- /dev/null +++ b/database/migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php @@ -0,0 +1,29 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php b/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php new file mode 100644 index 000000000..384c46223 --- /dev/null +++ b/database/migrations/2026_06_19_142000_v5_create_resource_connections_table.php @@ -0,0 +1,58 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php b/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php new file mode 100644 index 000000000..ce87055ad --- /dev/null +++ b/database/migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php @@ -0,0 +1,38 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_06_19_182231_create_container_statuses_table.php b/database/migrations/2026_06_19_182231_create_container_statuses_table.php new file mode 100644 index 000000000..a4890e780 --- /dev/null +++ b/database/migrations/2026_06_19_182231_create_container_statuses_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index dcc64d2f7..46bf06a95 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -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); diff --git a/database/seeders/V5DevLimaSeeder.php b/database/seeders/V5DevLimaSeeder.php index ea796622d..92bcf573f 100644 --- a/database/seeders/V5DevLimaSeeder.php +++ b/database/seeders/V5DevLimaSeeder.php @@ -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 + * @return array */ 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', ], diff --git a/docker/development/etc/s6-overlay/s6-rc.d/flux/run b/docker/development/etc/s6-overlay/s6-rc.d/flux/run index d99bdaac4..9d8598cf9 100755 --- a/docker/development/etc/s6-overlay/s6-rc.d/flux/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/flux/run @@ -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")" diff --git a/docker/production/etc/s6-overlay/s6-rc.d/flux/run b/docker/production/etc/s6-overlay/s6-rc.d/flux/run index d99bdaac4..9d8598cf9 100755 --- a/docker/production/etc/s6-overlay/s6-rc.d/flux/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/flux/run @@ -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")" diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 028652d80..79e59ae69 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -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)" diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 8ccacb8a0..88c634490 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -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)" diff --git a/resources/css/v5/app.css b/resources/css/v5/app.css index 519c1ecd4..e9b44ecca 100644 --- a/resources/css/v5/app.css +++ b/resources/css/v5/app.css @@ -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; diff --git a/resources/js/v5/Pages/Clusters.tsx b/resources/js/v5/Pages/Clusters.tsx index 21ffbeae4..a7363b7a6 100644 --- a/resources/js/v5/Pages/Clusters.tsx +++ b/resources/js/v5/Pages/Clusters.tsx @@ -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({}); const [editingServer, setEditingServer] = useState(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({}); @@ -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 (
-
-
+
+

{server.name}

{server.host}

-
+
{!isServerInitialized ? (
@@ -842,6 +848,12 @@ export default function Clusters({
{server.builderCpuQuota}
) : null} +
+
Caddy ingress
+
+ {server.ingressEnabled ? 'Enabled' : 'Disabled'} +
+
WireGuard IP
@@ -1611,6 +1623,17 @@ export default function Clusters({ Enable builder on this server + + + setServerIngressEnabled(event.target.checked) + } + /> + Enable Caddy ingress on this server + + WireGuard listen override Edit server - 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.
- - setEditServerBuilderEnabled(event.target.checked)} - /> - Enable builder on this server - +
+ + setEditServerBuilderEnabled(event.target.checked)} + /> + Enable builder on this server + + + + setEditServerIngressEnabled(event.target.checked)} + /> + Enable Caddy ingress on this server + +
diff --git a/resources/js/v5/Pages/Dashboard.tsx b/resources/js/v5/Pages/Dashboard.tsx index ab0ee6f1e..ec0375ebf 100644 --- a/resources/js/v5/Pages/Dashboard.tsx +++ b/resources/js/v5/Pages/Dashboard.tsx @@ -1,14 +1,1027 @@ import { Head } from '@inertiajs/react'; +import { useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, type WheelEvent } from 'react'; import { AppNavbar } from '@/components/app-navbar'; -import type { V5DashboardProps } from '@/types'; +import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision'; +import { csrfToken } from '@/lib/csrf'; +import { cn } from '@/lib/utils'; +import type { V5Application, V5CaddyIngress, V5DashboardProps, V5ResourceConnection } from '@/types'; + +type Viewport = { + x: number; + y: number; + zoom: number; +}; + +type ConnectorSide = 'top' | 'right' | 'bottom' | 'left'; + +type ConnectionEndpoint = { + applicationId: string; + side: ConnectorSide; +}; + +type V5CanvasResourceUpdatedEvent = { + application: V5Application | null; + caddyIngress: V5CaddyIngress | null; +}; + +type EchoChannel = { + listen: (event: string, callback: (payload: unknown) => void) => EchoChannel; + subscribed?: (callback: () => void) => EchoChannel; + error?: (callback: (error: unknown) => void) => EchoChannel; +}; + +type EchoClient = { + private: (channel: string) => EchoChannel; + leave?: (channel: string) => void; + leaveChannel?: (channel: string) => void; +}; + +declare global { + interface Window { + Echo?: EchoClient; + } +} + +type CanvasConnection = V5ResourceConnection; + +type DraftConnection = { + from: ConnectionEndpoint; + toX: number; + toY: number; +}; + +type PointerState = + | { + type: 'pan'; + pointerId: number; + startClientX: number; + startClientY: number; + startViewport: Viewport; + } + | { + type: 'app'; + pointerId: number; + applicationId: string; + startClientX: number; + startClientY: number; + startX: number; + startY: number; + } + | { + type: 'ingress'; + pointerId: number; + ingressId: string; + startClientX: number; + startClientY: number; + startX: number; + startY: number; + } + | { + type: 'connection'; + pointerId: number; + from: ConnectionEndpoint; + }; + +const APPLICATION_CARD_WIDTH = 320; +const APPLICATION_CARD_HEIGHT = 136; +const CANVAS_CARD_GAP = 16; +const CONNECTOR_SIDES: ConnectorSide[] = ['top', 'right', 'bottom', 'left']; +const MIN_CANVAS_ZOOM = 0.5; +const MAX_CANVAS_ZOOM = 2; +const CANVAS_ZOOM_STEP = 0.1; +const PINCH_CANVAS_ZOOM_STEP = 0.03; + +async function persistApplicationPosition(application: V5Application): Promise { + await fetch(`/v5/applications/${application.id}/position`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + canvas_x: application.canvasX, + canvas_y: application.canvasY, + }), + }); +} + +async function persistCaddyIngressPosition(ingress: V5CaddyIngress): Promise { + await fetch(`/v5/caddy-ingresses/${ingress.id}/position`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + canvas_x: ingress.canvasX, + canvas_y: ingress.canvasY, + }), + }); +} export default function Dashboard({ flux, + currentTeam = null, + applications: initialApplications = [], + caddyIngresses = [], + resourceConnections: initialResourceConnections = [], + nginxServers = [], projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null, }: V5DashboardProps) { + const [applications, setApplications] = useState(initialApplications); + const [ingresses, setIngresses] = useState(caddyIngresses); + const [connections, setConnections] = useState(initialResourceConnections); + const [selectedConnectionId, setSelectedConnectionId] = useState(null); + const [selectedApplicationId, setSelectedApplicationId] = useState(null); + const [connectionPortInput, setConnectionPortInput] = useState>({}); + const [draftConnection, setDraftConnection] = useState(null); + const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); + const [pointerState, setPointerState] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [selectedNginxServerId, setSelectedNginxServerId] = useState(nginxServers[0]?.id ?? ''); + const [isRefreshing, setIsRefreshing] = useState(false); + const [notice, setNotice] = useState(null); + const canvasRef = useRef(null); + const hasCanvasNodes = applications.length > 0 || ingresses.length > 0; + + const statusCounts = useMemo( + () => ({ + running: applications.filter((application) => application.status === 'running').length, + failed: applications.filter((application) => application.status === 'failed').length, + }), + [applications], + ); + + useEffect(() => { + const settledResources = settleCanvasResources(initialApplications, caddyIngresses); + + setApplications(settledResources.applications); + setIngresses(settledResources.ingresses); + setConnections(initialResourceConnections); + setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || ''); + setSelectedConnectionId(null); + setSelectedApplicationId(null); + centerOnCanvasNodes(settledResources.applications, settledResources.ingresses); + }, [initialApplications, caddyIngresses, initialResourceConnections, selectedProjectUuid, selectedEnvironmentUuid]); + + useEffect(() => { + if (!currentTeam) { + return; + } + + let isCancelled = false; + let attempts = 0; + const channelName = `team.${currentTeam.id}`; + + const interval = window.setInterval(() => { + attempts += 1; + + if (!window.Echo) { + if (attempts === 1) { + console.debug('Waiting for window.Echo before subscribing to canvas updates'); + } + + if (attempts >= 20) { + window.clearInterval(interval); + } + + return; + } + + window.clearInterval(interval); + + if (isCancelled) { + return; + } + + const channel = window.Echo.private(channelName); + + channel.subscribed?.(() => console.debug(`Subscribed to private-${channelName} for canvas updates`)); + channel.error?.((error) => console.error(`Subscription error on private-${channelName}`, error)); + channel.listen('.v5.canvas.resource.updated', (payload) => { + const event = payload as V5CanvasResourceUpdatedEvent; + + if (event.application) { + setApplications((currentApplications) => + currentApplications.map((application) => + application.id === event.application?.id ? event.application : application, + ), + ); + } + + if (event.caddyIngress) { + setIngresses((currentIngresses) => + currentIngresses.map((ingress) => + ingress.id === event.caddyIngress?.id ? event.caddyIngress : ingress, + ), + ); + } + }); + }, 500); + + return () => { + isCancelled = true; + window.clearInterval(interval); + window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`); + }; + }, [currentTeam]); + + useEffect(() => { + function deleteSelectedConnection(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + + if (target?.closest('input, textarea, [contenteditable="true"]')) { + return; + } + + if (!['Backspace', 'Delete'].includes(event.key) || !selectedConnectionId) { + return; + } + + deleteConnection(selectedConnectionId); + } + + window.addEventListener('keydown', deleteSelectedConnection); + + return () => window.removeEventListener('keydown', deleteSelectedConnection); + }, [selectedConnectionId]); + + function connectorPoint(endpoint: ConnectionEndpoint): { x: number; y: number } | null { + const application = applications.find((candidate) => candidate.id === endpoint.applicationId); + + if (!application) { + return null; + } + + switch (endpoint.side) { + case 'top': + return { x: application.canvasX + APPLICATION_CARD_WIDTH / 2, y: application.canvasY }; + case 'right': + return { x: application.canvasX + APPLICATION_CARD_WIDTH, y: application.canvasY + APPLICATION_CARD_HEIGHT / 2 }; + case 'bottom': + return { x: application.canvasX + APPLICATION_CARD_WIDTH / 2, y: application.canvasY + APPLICATION_CARD_HEIGHT }; + case 'left': + return { x: application.canvasX, y: application.canvasY + APPLICATION_CARD_HEIGHT / 2 }; + } + } + + function applicationConnectorPoints(applicationId: string): Array<{ side: ConnectorSide; x: number; y: number }> { + return CONNECTOR_SIDES.flatMap((side) => { + const point = connectorPoint({ applicationId, side }); + + return point ? [{ side, ...point }] : []; + }); + } + + function shortestConnectionPoints(connection: CanvasConnection): { from: { x: number; y: number }; to: { x: number; y: number } } | null { + const fromPoints = applicationConnectorPoints(connection.fromApplicationId); + const toPoints = applicationConnectorPoints(connection.toApplicationId); + let shortest: { from: { x: number; y: number }; to: { x: number; y: number }; distance: number } | null = null; + + for (const from of fromPoints) { + for (const to of toPoints) { + const distance = Math.hypot(from.x - to.x, from.y - to.y); + + if (!shortest || distance < shortest.distance) { + shortest = { from, to, distance }; + } + } + } + + return shortest ? { from: shortest.from, to: shortest.to } : null; + } + + function connectionExists(fromApplicationId: string, toApplicationId: string): boolean { + return connections.some( + (connection) => + (connection.fromApplicationId === fromApplicationId && connection.toApplicationId === toApplicationId) || + (connection.fromApplicationId === toApplicationId && connection.toApplicationId === fromApplicationId), + ); + } + + function applicationDirectionLabel(applicationId: string): string { + const application = applications.find((candidate) => candidate.id === applicationId); + + if (!application) { + return 'Unknown app'; + } + + return `${application.name} (${application.id.slice(0, 8)})`; + } + + function connectionDirectionKey(fromApplicationId: string, toApplicationId: string): string { + return `${fromApplicationId}->${toApplicationId}`; + } + + function activeConnectionPorts(connection: CanvasConnection): string[] { + return connection.portsByDirection[connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId)] ?? []; + } + +function normalizeConnection(connection: V5ResourceConnection): CanvasConnection { + return { + ...connection, + applicationIds: [connection.applicationIds[0], connection.applicationIds[1]], + portsByDirection: connection.portsByDirection ?? {}, + }; + } + + async function persistNewConnection(fromApplicationId: string, toApplicationId: string): Promise { + setNotice(null); + + try { + const response = await fetch('/v5/resource-connections', { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + resource_one: { type: 'application', id: Number(fromApplicationId) }, + resource_two: { type: 'application', id: Number(toApplicationId) }, + }), + }); + const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string }; + + if (!response.ok || !payload.connection) { + setNotice(payload.message ?? 'Could not save resource connection.'); + + return; + } + + const nextConnection = normalizeConnection(payload.connection); + + setConnections((currentConnections) => { + const withoutDuplicate = currentConnections.filter((connection) => connection.id !== nextConnection.id); + + return [...withoutDuplicate, nextConnection]; + }); + setSelectedConnectionId(nextConnection.id); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not save resource connection.'); + } + } + + async function persistConnectionPorts(connection: CanvasConnection): Promise { + const portsByDirection = Object.fromEntries( + Object.entries(connection.portsByDirection).map(([direction, ports]) => [ + direction, + ports.map((port) => Number(port)).filter((port) => Number.isInteger(port)), + ]), + ); + + try { + const response = await fetch(`/v5/resource-connections/${connection.id}`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ ports_by_direction: portsByDirection }), + }); + const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string }; + + if (!response.ok || !payload.connection) { + setNotice(payload.message ?? 'Could not save allowed ports.'); + + return; + } + + const nextConnection = normalizeConnection(payload.connection); + setConnections((currentConnections) => + currentConnections.map((currentConnection) => + currentConnection.id === nextConnection.id ? nextConnection : currentConnection, + ), + ); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not save allowed ports.'); + } + } + + async function deletePersistedConnection(connectionId: string): Promise { + try { + const response = await fetch(`/v5/resource-connections/${connectionId}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + + if (!response.ok) { + setNotice('Could not delete resource connection.'); + } + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not delete resource connection.'); + } + } + + function deleteConnection(connectionId: string): void { + setConnections((currentConnections) => currentConnections.filter((connection) => connection.id !== connectionId)); + setSelectedConnectionId(null); + void deletePersistedConnection(connectionId); + } + + function updateConnectionDirection(connectionId: string, fromApplicationId: string, toApplicationId: string): void { + setConnections((currentConnections) => + currentConnections.map((connection) => + connection.id === connectionId + ? { + ...connection, + fromApplicationId, + toApplicationId, + } + : connection, + ), + ); + } + + function addConnectionPort(connectionId: string): void { + const port = connectionPortInput[connectionId]?.trim(); + const portNumber = Number(port); + + if (!port || !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { + return; + } + + const connection = connections.find((candidate) => candidate.id === connectionId); + + if (!connection) { + return; + } + + const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId); + const directionPorts = connection.portsByDirection[directionKey] ?? []; + + if (directionPorts.includes(port)) { + return; + } + + const updatedConnection = { + ...connection, + portsByDirection: { + ...connection.portsByDirection, + [directionKey]: [...directionPorts, port], + }, + }; + + setConnections((currentConnections) => + currentConnections.map((currentConnection) => + currentConnection.id === updatedConnection.id ? updatedConnection : currentConnection, + ), + ); + void persistConnectionPorts(updatedConnection); + setConnectionPortInput((currentInputs) => ({ ...currentInputs, [connectionId]: '' })); + } + + function removeConnectionPort(connectionId: string, port: string): void { + const connection = connections.find((candidate) => candidate.id === connectionId); + + if (!connection) { + return; + } + + const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId); + const updatedConnection = { + ...connection, + portsByDirection: { + ...connection.portsByDirection, + [directionKey]: activeConnectionPorts(connection).filter((allowedPort) => allowedPort !== port), + }, + }; + + setConnections((currentConnections) => + currentConnections.map((currentConnection) => + currentConnection.id === updatedConnection.id ? updatedConnection : currentConnection, + ), + ); + void persistConnectionPorts(updatedConnection); + } + + function settleCanvasResources( + nextApplications: V5Application[], + nextIngresses: V5CaddyIngress[], + ): { applications: V5Application[]; ingresses: V5CaddyIngress[] } { + const settledNodes = resolveCanvasNodeLayout( + [ + ...nextApplications.map((application) => ({ + id: `application-${application.id}`, + x: application.canvasX, + y: application.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + })), + ...nextIngresses.map((ingress) => ({ + id: `ingress-${ingress.id}`, + x: ingress.canvasX, + y: ingress.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + })), + ], + CANVAS_CARD_GAP, + ); + const positionsById = new Map(settledNodes.map((node) => [node.id, node])); + + return { + applications: nextApplications.map((application) => { + const position = positionsById.get(`application-${application.id}`); + + return position ? { ...application, canvasX: position.x, canvasY: position.y } : application; + }), + ingresses: nextIngresses.map((ingress) => { + const position = positionsById.get(`ingress-${ingress.id}`); + + return position ? { ...ingress, canvasX: position.x, canvasY: position.y } : ingress; + }), + }; + } + + function canvasCollisionNodes(): CanvasNodeBounds[] { + return [ + ...applications.map((application) => ({ + id: `application-${application.id}`, + x: application.canvasX, + y: application.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + })), + ...ingresses.map((ingress) => ({ + id: `ingress-${ingress.id}`, + x: ingress.canvasX, + y: ingress.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + })), + ]; + } + + function resolveApplicationPosition(application: V5Application): V5Application { + const position = resolveCanvasNodePosition( + { + id: `application-${application.id}`, + x: application.canvasX, + y: application.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + }, + canvasCollisionNodes(), + CANVAS_CARD_GAP, + ); + + return { ...application, canvasX: position.x, canvasY: position.y }; + } + + function resolveIngressPosition(ingress: V5CaddyIngress): V5CaddyIngress { + const position = resolveCanvasNodePosition( + { + id: `ingress-${ingress.id}`, + x: ingress.canvasX, + y: ingress.canvasY, + width: APPLICATION_CARD_WIDTH, + height: APPLICATION_CARD_HEIGHT, + }, + canvasCollisionNodes(), + CANVAS_CARD_GAP, + ); + + return { ...ingress, canvasX: position.x, canvasY: position.y }; + } + + function canvasPointFromPointer(event: PointerEvent): { x: number; y: number } { + const rect = canvasRef.current?.getBoundingClientRect(); + + if (!rect) { + return { x: 0, y: 0 }; + } + + return { + x: (event.clientX - rect.left - viewport.x) / viewport.zoom, + y: (event.clientY - rect.top - viewport.y) / viewport.zoom, + }; + } + + async function removeApplication(application: V5Application): Promise { + setNotice(null); + + try { + const response = await fetch(`/v5/applications/${application.id}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + + if (!response.ok) { + setNotice('Could not delete application.'); + + return; + } + + setApplications((currentApplications) => + currentApplications.filter((candidate) => candidate.id !== application.id), + ); + setConnections((currentConnections) => + currentConnections.filter( + (connection) => + connection.fromApplicationId !== application.id && connection.toApplicationId !== application.id, + ), + ); + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not delete application.'); + } + } + + async function addNginx(): Promise { + setIsCreating(true); + setNotice(null); + + try { + const response = await fetch('/v5/applications/nginx', { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + body: JSON.stringify({ + server_id: selectedNginxServerId || null, + }), + }); + const payload = (await response.json()) as { application?: V5Application; message?: string }; + + if (payload.application) { + const settledResources = settleCanvasResources([...applications, payload.application], ingresses); + const settledApplication = settledResources.applications.find( + (application) => application.id === payload.application?.id, + ); + + setApplications(settledResources.applications); + setIngresses(settledResources.ingresses); + centerOnCanvasNodes(settledResources.applications, settledResources.ingresses); + + if ( + settledApplication && + (settledApplication.canvasX !== payload.application.canvasX || + settledApplication.canvasY !== payload.application.canvasY) + ) { + void persistApplicationPosition(settledApplication); + } + } + + if (!response.ok) { + setNotice(payload.application?.statusMessage ?? payload.message ?? 'Could not deploy nginx.'); + } + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not deploy nginx.'); + } finally { + setIsCreating(false); + } + } + + async function refreshApplications(): Promise { + setIsRefreshing(true); + setNotice(null); + + try { + const response = await fetch('/v5/applications/refresh', { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken(), + }, + }); + const payload = (await response.json()) as { + applications?: V5Application[]; + errors?: string[]; + message?: string; + }; + + if (payload.applications) { + const settledResources = settleCanvasResources(payload.applications, ingresses); + + setApplications(settledResources.applications); + setIngresses(settledResources.ingresses); + } + + if (!response.ok) { + setNotice(payload.message ?? 'Could not refresh application state.'); + } else if (payload.errors && payload.errors.length > 0) { + setNotice(payload.errors[0] ?? 'Could not refresh all application state.'); + } + } catch (error) { + setNotice(error instanceof Error ? error.message : 'Could not refresh application state.'); + } finally { + setIsRefreshing(false); + } + } + + function centerOnCanvasNodes( + nextApplications = applications, + nextCaddyIngresses: V5CaddyIngress[] = ingresses, + ): void { + const canvas = canvasRef.current; + const nodes = [...nextApplications, ...nextCaddyIngresses]; + + if (!canvas || nodes.length === 0) { + setViewport((currentViewport) => ({ x: 0, y: 0, zoom: currentViewport.zoom })); + + return; + } + + const bounds = nodes.reduce( + (currentBounds, node) => ({ + minX: Math.min(currentBounds.minX, node.canvasX), + maxX: Math.max(currentBounds.maxX, node.canvasX), + minY: Math.min(currentBounds.minY, node.canvasY), + maxY: Math.max(currentBounds.maxY, node.canvasY), + }), + { + minX: nodes[0]?.canvasX ?? 0, + maxX: nodes[0]?.canvasX ?? 0, + minY: nodes[0]?.canvasY ?? 0, + maxY: nodes[0]?.canvasY ?? 0, + }, + ); + const centerX = (bounds.minX + bounds.maxX) / 2; + const centerY = (bounds.minY + bounds.maxY) / 2; + const rect = canvas.getBoundingClientRect(); + + setViewport((currentViewport) => ({ + x: rect.width / 2 - (centerX + APPLICATION_CARD_WIDTH / 2) * currentViewport.zoom, + y: rect.height / 2 - (centerY + APPLICATION_CARD_HEIGHT / 2) * currentViewport.zoom, + zoom: currentViewport.zoom, + })); + } + + function startPan(event: PointerEvent): void { + if (event.target !== event.currentTarget) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + setPointerState({ + type: 'pan', + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startViewport: viewport, + }); + } + + function startApplicationDrag(event: PointerEvent, application: V5Application): void { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setSelectedConnectionId(null); + setSelectedApplicationId(application.id); + setPointerState({ + type: 'app', + pointerId: event.pointerId, + applicationId: application.id, + startClientX: event.clientX, + startClientY: event.clientY, + startX: application.canvasX, + startY: application.canvasY, + }); + } + + function startIngressDrag(event: PointerEvent, ingress: V5CaddyIngress): void { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + setPointerState({ + type: 'ingress', + pointerId: event.pointerId, + ingressId: ingress.id, + startClientX: event.clientX, + startClientY: event.clientY, + startX: ingress.canvasX, + startY: ingress.canvasY, + }); + } + + function startConnectionDrag( + event: PointerEvent, + applicationId: string, + side: ConnectorSide, + ): void { + event.stopPropagation(); + + const from = { applicationId, side }; + const startPoint = connectorPoint(from) ?? canvasPointFromPointer(event); + + setDraftConnection({ + from, + toX: startPoint.x, + toY: startPoint.y, + }); + setPointerState({ + type: 'connection', + pointerId: event.pointerId, + from, + }); + } + + function selectConnection(event: MouseEvent, connectionId: string): void { + event.stopPropagation(); + setSelectedConnectionId(connectionId); + setSelectedApplicationId(null); + } + + function clearCanvasSelection(event: MouseEvent): void { + if (event.target !== event.currentTarget) { + return; + } + + setSelectedConnectionId(null); + setSelectedApplicationId(null); + } + + function connectionTargetFromPointer(event: PointerEvent): HTMLElement | null { + const pointerTarget = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; + + // Touch browsers may keep pointer-captured mobile drags targeted at the origin connector. + return pointerTarget?.closest('[data-application-card]') ?? null; + } + + function movePointer(event: PointerEvent): void { + if (!pointerState || pointerState.pointerId !== event.pointerId) { + return; + } + + if (pointerState.type === 'connection') { + const point = canvasPointFromPointer(event); + + setDraftConnection({ + from: pointerState.from, + toX: point.x, + toY: point.y, + }); + + return; + } + + const deltaX = event.clientX - pointerState.startClientX; + const deltaY = event.clientY - pointerState.startClientY; + + if (pointerState.type === 'pan') { + setViewport({ + x: pointerState.startViewport.x + deltaX, + y: pointerState.startViewport.y + deltaY, + zoom: pointerState.startViewport.zoom, + }); + + return; + } + + if (pointerState.type === 'app') { + setApplications((currentApplications) => + currentApplications.map((application) => + application.id === pointerState.applicationId + ? { + ...application, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + } + : application, + ), + ); + + return; + } + + setIngresses((currentIngresses) => + currentIngresses.map((ingress) => + ingress.id === pointerState.ingressId + ? { + ...ingress, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + } + : ingress, + ), + ); + } + + function clampCanvasZoom(zoom: number): number { + return Math.min(MAX_CANVAS_ZOOM, Math.max(MIN_CANVAS_ZOOM, zoom)); + } + + function zoomCanvas(direction: 1 | -1, step = CANVAS_ZOOM_STEP, origin?: { x: number; y: number }): void { + const rect = canvasRef.current?.getBoundingClientRect(); + + setViewport((currentViewport) => { + const nextZoom = clampCanvasZoom(currentViewport.zoom + direction * step); + + if (!rect || nextZoom === currentViewport.zoom) { + return currentViewport; + } + + const originX = origin?.x ?? rect.width / 2; + const originY = origin?.y ?? rect.height / 2; + const canvasX = (originX - currentViewport.x) / currentViewport.zoom; + const canvasY = (originY - currentViewport.y) / currentViewport.zoom; + + return { + x: originX - canvasX * nextZoom, + y: originY - canvasY * nextZoom, + zoom: nextZoom, + }; + }); + } + + function handleCanvasWheel(event: WheelEvent): void { + if (!event.ctrlKey) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + + event.preventDefault(); + zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP, { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }); + } + + function stopPointer(event: PointerEvent): void { + if (!pointerState || pointerState.pointerId !== event.pointerId) { + return; + } + + if (pointerState.type === 'connection') { + const target = connectionTargetFromPointer(event); + const targetApplicationId = target?.dataset.applicationId; + + if ( + targetApplicationId && + targetApplicationId !== pointerState.from.applicationId && + !connectionExists(pointerState.from.applicationId, targetApplicationId) + ) { + void persistNewConnection(pointerState.from.applicationId, targetApplicationId); + } + + setDraftConnection(null); + setPointerState(null); + + return; + } + + const deltaX = event.clientX - pointerState.startClientX; + const deltaY = event.clientY - pointerState.startClientY; + + if (pointerState.type === 'app') { + const application = applications.find((candidate) => candidate.id === pointerState.applicationId); + + if (application) { + const updatedApplication = resolveApplicationPosition({ + ...application, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + }); + + setApplications((currentApplications) => + currentApplications.map((candidate) => (candidate.id === updatedApplication.id ? updatedApplication : candidate)), + ); + void persistApplicationPosition(updatedApplication); + } + } + + if (pointerState.type === 'ingress') { + const ingress = ingresses.find((candidate) => candidate.id === pointerState.ingressId); + + if (ingress) { + const updatedIngress = resolveIngressPosition({ + ...ingress, + canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom), + canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom), + }); + + setIngresses((currentIngresses) => + currentIngresses.map((candidate) => (candidate.id === updatedIngress.id ? updatedIngress : candidate)), + ); + void persistCaddyIngressPosition(updatedIngress); + } + } + + setPointerState(null); + } + return ( <> @@ -21,11 +1034,453 @@ export default function Dashboard({ selectedEnvironmentUuid={selectedEnvironmentUuid} /> -
-
-

Dashboard

-

This is where the magic happens.

-
+
+
+ + + +
+ + + {Math.round(viewport.zoom * 100)}% + + +
+ +
+ {applications.length} apps + • + {statusCounts.running} running + {statusCounts.failed > 0 && ( + <> + • + {statusCounts.failed} failed + + )} +
+
+ + {notice && ( +
+ {notice} +
+ )} + +
+ {!hasCanvasNodes && ( +
+

No applications on this canvas yet.

+

+ Click Add nginx to deploy a test container on one of your v5 servers. +

+
+ )} + +
+ {ingresses.map((ingress) => ( +
startIngressDrag(event, ingress)} + > +
+
+
Caddy ingress
+
{ingress.name}
+
+ + {ingress.status} + +
+ +
+
+
Server
+
{ingress.name}
+
+
+
Host
+
+ {ingress.host} +
+
+
+
+ ))} + + + + + + + + {connections.map((connection) => { + const points = shortestConnectionPoints(connection); + + if (!points) { + return null; + } + + return ( + + event.stopPropagation()} + onClick={(event) => selectConnection(event, connection.id)} + /> + + + ); + })} + {draftConnection && + (() => { + const from = connectorPoint(draftConnection.from); + + if (!from) { + return null; + } + + return ( + + ); + })()} + + + {connections.map((connection) => { + if (connection.id !== selectedConnectionId) { + return null; + } + + const points = shortestConnectionPoints(connection); + + if (!points) { + return null; + } + + const activePorts = activeConnectionPorts(connection); + const firstApplicationId = connection.applicationIds[0]; + const secondApplicationId = connection.applicationIds[1]; + const isForwardDirection = + connection.fromApplicationId === firstApplicationId && + connection.toApplicationId === secondApplicationId; + + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
+
+ Firewall +
+
+ + +
+
+ +
+
+ Allowed ports +
+
+ {activePorts.length === 0 && ( + No ports yet. + )} + {activePorts.map((port) => ( + + ))} +
+
+ + setConnectionPortInput((currentInputs) => ({ + ...currentInputs, + [connection.id]: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + addConnectionPort(connection.id); + } + }} + className="min-w-0 flex-1 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground outline-none transition focus:border-warning" + /> + +
+
+ + +
+ ); + })} + + {applications.map((application) => ( +
startApplicationDrag(event, application)} + > + {CONNECTOR_SIDES.map((side) => ( + + ))} + +
+
+
{application.name}
+
{application.image}
+
+
+ + {application.status} + + +
+
+ +
+
+
Server
+
+ {application.serverName ?? 'Unknown'} +
+
+
+
Container
+
+ {application.containerName} +
+
+
+
+ ))} +
+
diff --git a/resources/js/v5/components/app-navbar.tsx b/resources/js/v5/components/app-navbar.tsx index be1534073..a853f5d67 100644 --- a/resources/js/v5/components/app-navbar.tsx +++ b/resources/js/v5/components/app-navbar.tsx @@ -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 { + 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) => ({ diff --git a/resources/js/v5/lib/canvas-collision.ts b/resources/js/v5/lib/canvas-collision.ts new file mode 100644 index 000000000..a6c6bff28 --- /dev/null +++ b/resources/js/v5/lib/canvas-collision.ts @@ -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((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); +} diff --git a/resources/js/v5/types.ts b/resources/js/v5/types.ts index 0f3b6a8b8..9164509be 100644 --- a/resources/js/v5/types.ts +++ b/resources/js/v5/types.ts @@ -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; +}; + export type V5DashboardProps = { flux: FluxStatus | null; currentTeam?: { id: number; } | null; + applications?: V5Application[]; + caddyIngresses?: V5CaddyIngress[]; + resourceConnections?: V5ResourceConnection[]; + nginxServers?: V5NginxServer[]; clusters?: V5Cluster[]; privateKeys?: V5PrivateKey[]; projects?: V5Project[]; diff --git a/routes/api.php b/routes/api.php index ec6bde2e6..6129da7e7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); }); diff --git a/routes/v5.php b/routes/v5.php index cd5daf23e..e7986157f 100644 --- a/routes/v5.php +++ b/routes/v5.php @@ -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'); diff --git a/scripts/coold-vm.sh b/scripts/coold-vm.sh index ea7b9c994..0c489ae68 100755 --- a/scripts/coold-vm.sh +++ b/scripts/coold-vm.sh @@ -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' diff --git a/scripts/dev.sh b/scripts/dev.sh index 9a1102ef6..4a86db1c8 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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..." diff --git a/scripts/install.sh b/scripts/install.sh index 430dd9d83..292eb5de1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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)" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index fb27694b0..3acea7781 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -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)" diff --git a/tests/Feature/ContainerRoleScriptTest.php b/tests/Feature/ContainerRoleScriptTest.php index 5d55a3877..f53864d4b 100644 --- a/tests/Feature/ContainerRoleScriptTest.php +++ b/tests/Feature/ContainerRoleScriptTest.php @@ -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'); + } +}); diff --git a/tests/Feature/DevScriptFirewallDelegationTest.php b/tests/Feature/DevScriptFirewallDelegationTest.php index 1cd46efc1..9c6669478 100644 --- a/tests/Feature/DevScriptFirewallDelegationTest.php +++ b/tests/Feature/DevScriptFirewallDelegationTest.php @@ -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"'); diff --git a/tests/Feature/V5/DashboardTest.php b/tests/Feature/V5/DashboardTest.php index 4b4a77f84..39a95fe51 100644 --- a/tests/Feature/V5/DashboardTest.php +++ b/tests/Feature/V5/DashboardTest.php @@ -1,5 +1,7 @@ and(Route::has('v5.clusters.servers.check'))->toBeTrue() ->and(Route::has('v5.clusters.servers.bootstrap'))->toBeTrue() ->and(Route::has('v5.clusters.servers.destroy'))->toBeTrue() + ->and(Route::has('v5.applications.nginx'))->toBeTrue() + ->and(Route::has('v5.applications.refresh'))->toBeTrue() + ->and(Route::has('v5.applications.position'))->toBeTrue() + ->and(Route::has('v5.caddy-ingresses.position'))->toBeTrue() + ->and(Route::has('v5.applications.destroy'))->toBeTrue() + ->and(Route::has('v5.resource-connections.store'))->toBeTrue() + ->and(Route::has('v5.resource-connections.update'))->toBeTrue() + ->and(Route::has('v5.resource-connections.destroy'))->toBeTrue() ->and(Route::has('v5.realtime-test'))->toBeTrue() ->and(Route::has('v5.realtime-test.broadcast'))->toBeTrue() ->and(Route::has('v5.coolify.version'))->toBeFalse() @@ -82,6 +99,157 @@ it('reuses existing projects instead of creating v5 projects', function () { ->and(file_exists(app_path('Models/V5/Project.php')))->toBeFalse(); }); +it('keeps long v5 application metadata inside the canvas card', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('overflow-hidden') + ->and($dashboardSource)->toContain('grid grid-cols-[auto_minmax(0,1fr)]') + ->and($dashboardSource)->toContain('truncate text-right font-medium') + ->and($dashboardSource)->toContain('truncate text-right font-mono'); +}); + +it('does not render v5 application status messages on dashboard cards', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->not->toContain('{application.statusMessage && (') + ->not->toContain('{application.statusMessage}

'); +}); + +it('shows a dashboard refresh button next to the center button', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('onClick={() => centerOnCanvasNodes()}') + ->toContain('onClick={() => void refreshApplications()}') + ->toContain("isRefreshing ? 'Refreshing…' : 'Refresh state'"); +}); + +it('subscribes the v5 dashboard canvas to automatic resource status updates', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('currentTeam = null') + ->toContain("channel.listen('.v5.canvas.resource.updated'") + ->toContain('setApplications((currentApplications) =>') + ->toContain('setIngresses((currentIngresses) =>') + ->toContain('Waiting for window.Echo before subscribing to canvas updates') + ->toContain("channel.listen('.v5.canvas.resource.updated'") + ->not->toContain("fetch('/v5/canvas-state'"); +}); + +it('allows zooming the v5 dashboard canvas with buttons and pinch gestures', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('zoom: number;') + ->toContain('MIN_CANVAS_ZOOM') + ->toContain('MAX_CANVAS_ZOOM') + ->toContain('PINCH_CANVAS_ZOOM_STEP') + ->toContain('zoomCanvas(') + ->toContain('zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP') + ->toContain('onWheel={handleCanvasWheel}') + ->toContain('event.ctrlKey') + ->toContain('aria-label="Zoom out"') + ->toContain('aria-label="Zoom in"') + ->toContain('Math.round(viewport.zoom * 100)') + ->toContain('scale(${viewport.zoom})'); +}); + +it('renders draggable connector dots on v5 application canvas cards', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain("type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';") + ->toContain('application-connector') + ->toContain('data-connector-side={side}') + ->toContain('startConnectionDrag(event, application.id, side)') + ->toContain('') + ->toContain('draftConnection'); +}); + +it('keeps v5 canvas connections selectable unique and shortest-path only', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('type CanvasConnection = V5ResourceConnection;') + ->toContain('resourceConnections: initialResourceConnections = []') + ->toContain('useState(initialResourceConnections)') + ->toContain('selectedConnectionId') + ->toContain('clearCanvasSelection') + ->toContain('event.target !== event.currentTarget') + ->toContain('connectionExists') + ->toContain('shortestConnectionPoints') + ->toContain("!['Backspace', 'Delete'].includes(event.key)") + ->toContain('deletePersistedConnection(connectionId)') + ->toContain('onClick={(event) => selectConnection(event, connection.id)}') + ->toContain("selectedConnectionId === connection.id ? 'stroke-destructive' : 'stroke-warning'") + ->toContain('aria-label="Select connection"') + ->toContain('stroke="transparent"') + ->toContain('strokeWidth={12}') + ->toContain('data-application-card="application-card"') + ->toContain("closest('[data-application-card]')") + ->toContain('deleteConnection(connection.id)') + ->toContain('Delete connection') + ->toContain('left: (points.from.x + points.to.x) / 2') + ->toContain('top: (points.from.y + points.to.y) / 2') + ->toContain('id="dashboard-connection-arrow"') + ->toContain('markerEnd={selectedConnectionId === connection.id ? \'url(#dashboard-connection-arrow)\' : undefined}') + ->toContain('markerWidth="16"') + ->toContain('markerHeight="16"') + ->toContain('strokeDasharray="6 6"') + ->toContain('persistNewConnection(pointerState.from.applicationId, targetApplicationId)') + ->toContain('persistConnectionPorts(updatedConnection)') + ->toContain('/v5/resource-connections') + ->toContain('ports_by_direction: portsByDirection') + ->toContain('connectionDirectionKey(') + ->toContain('activeConnectionPorts(connection)') + ->toContain('addConnectionPort(connection.id)') + ->toContain('Number.isInteger(portNumber)') + ->toContain('setConnectionPortInput') + ->toContain('Allowed ports') + ->toContain('updateConnectionDirection(') + ->toContain('applicationDirectionLabel(') + ->toContain('application.id.slice(0, 8)') + ->toContain('connection.applicationIds[0]') + ->toContain('connection.applicationIds[1]') + ->toContain('group/application') + ->toContain('opacity-0') + ->toContain('group-hover/application:opacity-100'); +}); + +it('shows v5 application connector dots after selecting a canvas card', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('selectedApplicationId') + ->toContain('setSelectedApplicationId(application.id)') + ->toContain('selectedApplicationId === application.id') + ->toContain('opacity-100'); +}); + +it('uses a larger mobile touch target for v5 application connector dots', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('size-8') + ->toContain('md:size-3') + ->toContain('group/connector') + ->toContain('group-hover/connector:scale-125') + ->toContain(''); +}); + +it('detects the connection drop target from pointer coordinates for mobile drags', function () { + $dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx')); + + expect($dashboardSource) + ->toContain('connectionTargetFromPointer(event)') + ->toContain('document.elementFromPoint(event.clientX, event.clientY)') + ->toContain('pointer-captured mobile drags') + ->toContain('targetApplicationId !== pointerState.from.applicationId'); +}); + it('creates v5 cluster tables and lets each server belong to one cluster', function () { createSharedUserAndTeamTables(); Schema::dropIfExists('v5_servers'); @@ -174,6 +342,147 @@ it('creates v5 server tables in the shared database', function () { ]))->toBeTrue(); }); +it('adds v5 server canvas columns for movable caddy ingress nodes', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); + $clusterMigration->up(); + + $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); + $serverMigration->up(); + + $canvasMigration = include database_path('migrations/2026_06_19_141231_add_canvas_position_to_v5_servers_table.php'); + $canvasMigration->up(); + + expect(Schema::hasColumns('v5_servers', [ + 'canvas_x', + 'canvas_y', + ]))->toBeTrue(); +}); + +it('adds v5 server caddy ingress container status column', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); + $clusterMigration->up(); + + $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); + $serverMigration->up(); + + [$user, $team] = createV5UserWithTeam(); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-ingress-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold', 'ingress'], + ]); + + $statusMigration = include database_path('migrations/2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table.php'); + $statusMigration->up(); + + expect(Schema::hasColumn('v5_servers', 'caddy_ingress_status'))->toBeTrue() + ->and($server->refresh()->caddy_ingress_status)->toBe('running'); +}); + +it('creates v5 application tables for dashboard canvas nodes', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_applications'); + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); + $clusterMigration->up(); + + $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); + $serverMigration->up(); + + $applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php'); + $applicationMigration->up(); + + expect(Schema::hasTable('v5_applications'))->toBeTrue() + ->and(Schema::hasColumns('v5_applications', [ + 'id', + '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', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + +it('creates generic v5 resource connection tables', function () { + createSharedUserAndTeamTables(); + + Schema::dropIfExists('v5_resource_connection_rules'); + Schema::dropIfExists('v5_resource_connections'); + Schema::dropIfExists('v5_applications'); + Schema::dropIfExists('v5_servers'); + Schema::dropIfExists('v5_clusters'); + + $clusterMigration = include database_path('migrations/2026_06_16_130649_v5_create_clusters_table.php'); + $clusterMigration->up(); + + $serverMigration = include database_path('migrations/2026_06_16_130650_v5_create_servers_table.php'); + $serverMigration->up(); + + $applicationMigration = include database_path('migrations/2026_06_19_140000_v5_create_applications_table.php'); + $applicationMigration->up(); + + $connectionMigration = include database_path('migrations/2026_06_19_142000_v5_create_resource_connections_table.php'); + $connectionMigration->up(); + + expect(Schema::hasTable('v5_resource_connections'))->toBeTrue() + ->and(Schema::hasColumns('v5_resource_connections', [ + 'id', + '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', + 'created_at', + 'updated_at', + ]))->toBeTrue() + ->and(Schema::hasTable('v5_resource_connection_rules'))->toBeTrue() + ->and(Schema::hasColumns('v5_resource_connection_rules', [ + 'id', + 'connection_id', + 'source_resource_type', + 'source_resource_id', + 'target_resource_type', + 'target_resource_id', + 'protocol', + 'port', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + it('keeps v5 server fields in the initial migration', function () { createSharedUserAndTeamTables(); @@ -205,6 +514,8 @@ it('includes v5 tables in the dev testing schema', function () { ->and($schema)->not->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"') ->and($schema)->not->toContain('2026_06_04_050157_v5_create_projects_table') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_servers"') + ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_container_statuses"') + ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_applications"') ->and($schema)->toContain('"cluster_id" INTEGER') ->and($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_clusters"') ->and($schema)->toContain('"wireguard_interface" TEXT DEFAULT \'wg0\' NOT NULL') @@ -212,13 +523,21 @@ it('includes v5 tables in the dev testing schema', function () { ->and($schema)->toContain('"container_network_pool" TEXT DEFAULT \'10.210.0.0/16\' NOT NULL') ->and($schema)->toContain('"builder_timeout_secs" INTEGER NOT NULL DEFAULT \'1800\'') ->and($schema)->toContain('"private_key_id" INTEGER') + ->and($schema)->toContain('"caddy_ingress_status" TEXT') ->and($schema)->toContain('"builder_cpu_quota" TEXT DEFAULT \'200%\' NOT NULL') ->and($schema)->toContain('"uuid" TEXT') ->and($schema)->toContain('"wireguard_management_ip" TEXT') ->and($schema)->toContain('"container_subnets" JSON') + ->and($schema)->toContain('"canvas_x" INTEGER') + ->and($schema)->toContain('"canvas_y" INTEGER') ->and($schema)->toContain('"last_bootstrap_output" TEXT') ->and($schema)->toContain('"last_status_output" TEXT') ->and($schema)->toContain('2026_06_16_130650_v5_create_servers_table') + ->and($schema)->toContain('2026_06_19_140000_v5_create_applications_table') + ->and($schema)->toContain('2026_06_19_141231_add_canvas_position_to_v5_servers_table') + ->and($schema)->toContain('2026_06_19_173933_add_caddy_ingress_status_to_v5_servers_table') + ->and($schema)->toContain('2026_06_19_182231_create_container_statuses_table') + ->and($schema)->not->toContain('2026_06_19_150000_add_mesh_namespace_to_v5_applications_table') ->and($schema)->toContain('2026_06_16_130649_v5_create_clusters_table') ->and($schema)->not->toContain('2026_06_16_204644_v5_add_wireguard_cli_configuration_to_clusters_and_servers') ->and($schema)->not->toContain('2026_06_17_165112_v5_add_builder_cpu_quota_to_servers_table') @@ -239,6 +558,7 @@ it('serves the v5 inertia shell', function () { app()->detectEnvironment(fn () => 'local'); $this->withoutVite(); + $this->withoutExceptionHandling(); fakeFluxHealth(); createSharedUserAndTeamTables(); @@ -285,11 +605,1163 @@ it('serves the v5 inertia shell', function () { ->assertDontSee('Current team') ->assertDontSee('Your teams') ->assertSee('currentTeam', false) + ->assertSee('"currentTeam":{"id":'.$team->id, false) ->assertDontSee('teams', false) ->assertDontSee('V5 Shared Team') ->assertDontSee('Shared team details'); }); +it('serves v5 dashboard applications as canvas nodes', function () { + app()->detectEnvironment(fn () => 'local'); + + $this->withoutVite(); + fakeFluxHealth(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($team, 'Staging Project', 'Staging'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + + V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'abc123', + 'mesh_namespace' => 'default', + 'canvas_x' => 120, + 'canvas_y' => -80, + ]); + V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $otherProject->id, + 'environment_id' => $otherEnvironment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'other-nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-other', + 'status' => 'running', + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->get('/v5') + ->assertSuccessful() + ->assertSee('"applications":[', false) + ->assertSee('"name":"nginx-test"', false) + ->assertSee('"serverName":"edge-01"', false) + ->assertSee('"meshNamespace":"default"', false) + ->assertSee('"meshFqdn":"coolify-v5-nginx-1.default.coolify.internal"', false) + ->assertSee('"canvasX":120', false) + ->assertSee('"canvasY":-80', false) + ->assertDontSee('other-nginx-test', false); +}); + +it('persists generic v5 resource connections and direction-specific ports', function () { + app()->detectEnvironment(fn () => 'local'); + + $this->withoutVite(); + fakeFluxHealth(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $source = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-source', + 'status' => 'running', + ]); + $target = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-target', + 'status' => 'running', + ]); + + $response = $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + '_token' => 'test-csrf-token', + ]) + ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') + ->postJson('/v5/resource-connections', [ + 'resource_one' => ['type' => 'application', 'id' => $source->id], + 'resource_two' => ['type' => 'application', 'id' => $target->id], + ]) + ->assertCreated() + ->assertJsonPath('connection.applicationIds.0', (string) $source->id) + ->assertJsonPath('connection.applicationIds.1', (string) $target->id); + + $connectionId = $response->json('connection.id'); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token']) + ->withHeader('X-CSRF-TOKEN', 'test-csrf-token') + ->patchJson("/v5/resource-connections/{$connectionId}", [ + 'ports_by_direction' => [ + "{$source->id}->{$target->id}" => [80], + "{$target->id}->{$source->id}" => [443], + ], + ]) + ->assertSuccessful() + ->assertJsonPath("connection.portsByDirection.{$source->id}->{$target->id}.0", '80') + ->assertJsonPath("connection.portsByDirection.{$target->id}->{$source->id}.0", '443'); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->get('/v5') + ->assertSuccessful() + ->assertSee('"resourceConnections":[', false) + ->assertSee("\"id\":\"{$connectionId}\"", false) + ->assertSee("\"{$source->id}->{$target->id}\":[\"80\"]", false) + ->assertSee("\"{$target->id}->{$source->id}\":[\"443\"]", false); +}); + +it('serves enabled v5 caddy ingress servers as canvas nodes', function () { + app()->detectEnvironment(fn () => 'local'); + + $this->withoutVite(); + fakeFluxHealth(); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-ingress-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'caddy_ingress_status' => 'running', + 'capabilities' => ['coold', 'ingress'], + 'canvas_x' => -160, + 'canvas_y' => 240, + ]); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-ingress-02', + 'host' => '203.0.113.22', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'caddy_ingress_status' => 'exited', + 'capabilities' => ['coold', 'ingress'], + ]); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-worker-01', + 'host' => '203.0.113.21', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->get('/v5') + ->assertSuccessful() + ->assertSee('"caddyIngresses":[', false) + ->assertSee('"name":"edge-ingress-01"', false) + ->assertSee('"host":"203.0.113.20"', false) + ->assertSee('"status":"running"', false) + ->assertSee('"name":"edge-ingress-02"', false) + ->assertSee('"status":"exited"', false) + ->assertSee('"canvasX":-160', false) + ->assertSee('"canvasY":240', false); +}); + +it('creates an nginx v5 application on the first installed team server', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + + Process::fake([ + '*' => Process::result(output: "nginx-container-id\n"), + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx') + ->assertCreated() + ->assertJsonPath('application.name', 'nginx-test') + ->assertJsonPath('application.image', 'docker.io/library/nginx:alpine') + ->assertJsonPath('application.status', 'running') + ->assertJsonPath('application.serverName', 'edge-01') + ->assertJsonPath('application.meshNamespace', 'default') + ->assertJsonPath('application.canvasX', 0) + ->assertJsonPath('application.canvasY', 0); + + expect(V5Application::query() + ->where('team_id', $team->id) + ->where('project_id', $project->id) + ->where('environment_id', $environment->id) + ->where('name', 'nginx-test') + ->where('status', 'running') + ->where('runtime_container_id', 'nginx-container-id') + ->exists())->toBeTrue(); +}); + +it('creates an nginx v5 application on the selected team server', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + $selectedServer = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-02', + 'host' => '203.0.113.11', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + + Process::fake([ + '*' => Process::result(output: "nginx-container-id\n"), + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx', [ + 'server_id' => $selectedServer->id, + ]) + ->assertCreated() + ->assertJsonPath('application.serverName', 'edge-02'); + + expect(V5Application::query() + ->where('server_id', $selectedServer->id) + ->where('runtime_container_id', 'nginx-container-id') + ->exists())->toBeTrue(); +}); + +it('places a new nginx v5 application next to existing canvas nodes', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + + V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-existing', + 'status' => 'running', + 'status_message' => 'Running.', + 'mesh_namespace' => 'default', + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + + Process::fake([ + '*' => Process::result(output: "nginx-container-id\n"), + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx') + ->assertCreated() + ->assertJsonPath('application.canvasX', 352) + ->assertJsonPath('application.canvasY', 0); +}); + +it('marks an nginx v5 application failed when the launch command fails', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + + Process::fake([ + '*' => Process::result(errorOutput: 'podman failed', exitCode: 1), + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx') + ->assertUnprocessable() + ->assertJsonPath('application.status', 'failed') + ->assertJsonPath('application.statusMessage', 'podman failed'); +}); + +it('does not create an nginx v5 application on another teams selected server', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$otherUser, $otherTeam] = createV5UserWithTeam('other@example.com'); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($otherTeam, 'Other SSH Key'); + $otherServer = V5Server::query()->create([ + 'team_id' => $otherTeam->id, + 'created_by_user_id' => $otherUser->id, + 'private_key_id' => $privateKey->id, + 'name' => 'other-edge-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'last_bootstrapped_at' => now(), + ]); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx', [ + 'server_id' => $otherServer->id, + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Add a v5 server before deploying nginx.'); + + expect(V5Application::query()->count())->toBe(0); +}); + +it('does not create an nginx v5 application without a team server', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/nginx') + ->assertUnprocessable() + ->assertJsonPath('message', 'Add a v5 server before deploying nginx.'); + + expect(V5Application::query()->count())->toBe(0); +}); + +it('deletes a v5 application for the current team', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->deleteJson("/v5/applications/{$application->id}") + ->assertNoContent(); + + expect(V5Application::query()->whereKey($application->id)->exists())->toBeFalse(); +}); + +it('stops and deletes the nginx container before deleting a v5 application', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'private_key_id' => $privateKey->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'runtime_container_id' => 'nginx-container-id', + ]); + + Process::fake([ + '*' => Process::result(), + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->deleteJson("/v5/applications/{$application->id}") + ->assertNoContent(); + + Process::assertRan(function ($process): bool { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return is_string($command) + && str_contains($command, '203.0.113.10') + && str_contains($command, 'podman rm -f') + && str_contains($command, 'coolify-v5-nginx-1'); + }); + expect(V5Application::query()->whereKey($application->id)->exists())->toBeFalse(); +}); + +it('does not delete another teams v5 application', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$otherUser, $otherTeam] = createV5UserWithTeam(); + [$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $otherTeam->id, + 'created_by_user_id' => $otherUser->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $application = V5Application::query()->create([ + 'team_id' => $otherTeam->id, + 'project_id' => $otherProject->id, + 'environment_id' => $otherEnvironment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $otherUser->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->deleteJson("/v5/applications/{$application->id}") + ->assertNotFound(); + + expect(V5Application::query()->whereKey($application->id)->exists())->toBeTrue(); +}); + +it('updates v5 application canvas position for the current team', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/applications/{$application->id}/position", [ + 'canvas_x' => 320, + 'canvas_y' => -160, + ]) + ->assertSuccessful() + ->assertJsonPath('application.canvasX', 320) + ->assertJsonPath('application.canvasY', -160); + + expect($application->refresh()->canvas_x)->toBe(320) + ->and($application->canvas_y)->toBe(-160); +}); + +it('updates v5 caddy ingress canvas position for the current team', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-ingress-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold', 'ingress'], + 'canvas_x' => -352, + 'canvas_y' => 0, + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/caddy-ingresses/{$server->id}/position", [ + 'canvas_x' => -160, + 'canvas_y' => 240, + ]) + ->assertSuccessful() + ->assertJsonPath('caddyIngress.canvasX', -160) + ->assertJsonPath('caddyIngress.canvasY', 240); + + expect($server->refresh()->canvas_x)->toBe(-160) + ->and($server->canvas_y)->toBe(240); +}); + +it('applies flux application status updates to the database and broadcasts to the team canvas', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'nginx-container-id', + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $resource = ApplyFluxResourceStatusUpdate::run([ + 'resource_type' => 'application', + 'host_id' => '100.64.0.5', + 'container_name' => 'coolify-v5-nginx-1', + 'container_id' => 'new-nginx-container-id', + 'status' => 'exited', + 'status_message' => 'Status received from coold through flux.', + ]); + + expect($resource)->toBeInstanceOf(V5Application::class) + ->and($application->refresh()->status)->toBe('exited') + ->and($application->status_message)->toBe('Status received from coold through flux.') + ->and($application->runtime_container_id)->toBe('new-nginx-container-id'); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->applicationId === $application->id); +}); + +it('maps generic flux container status updates to v5 applications', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'nginx-container-id', + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $resource = ApplyFluxResourceStatusUpdate::run([ + 'resource_type' => 'container', + 'host_id' => '100.64.0.5', + 'container_name' => 'coolify-v5-nginx-1', + 'container_id' => 'nginx-container-id', + 'status' => 'exited', + 'status_message' => 'Container state received from coold.', + ]); + + expect($resource)->toBeInstanceOf(V5Application::class) + ->and($application->refresh()->status)->toBe('exited') + ->and($application->status_message)->toBe('Container state received from coold.'); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->applicationId === $application->id); +}); + +it('applies flux ingress server status updates to the database and broadcasts cluster plus canvas updates', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Cluster', + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold', 'ingress'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + + Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]); + + $resource = ApplyFluxResourceStatusUpdate::run([ + 'resource_type' => 'server', + 'host_id' => '100.64.0.5', + 'status' => 'unreachable', + 'message' => 'coold heartbeat timed out.', + ]); + + expect($resource)->toBeInstanceOf(V5Server::class) + ->and($server->refresh()->status)->toBe('unreachable') + ->and($server->last_status_check)->toBe('flux') + ->and($server->last_status_output)->toBe('coold heartbeat timed out.') + ->and($server->last_status_checked_at)->not->toBeNull(); + + Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id + && $event->clusterId === $cluster->id); + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->caddyIngressServerId === $server->id); +}); + +it('applies flux caddy ingress container status updates without changing server install status', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'caddy_ingress_status' => 'running', + 'capabilities' => ['coold', 'ingress'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $resource = ApplyFluxResourceStatusUpdate::run([ + 'resource_type' => 'caddy_ingress', + 'host_id' => '100.64.0.5', + 'container_name' => 'coolify-v5-caddy', + 'status' => 'exited', + 'message' => 'Caddy container exited.', + ]); + + expect($resource)->toBeInstanceOf(V5Server::class) + ->and($server->refresh()->status)->toBe('installed') + ->and($server->caddy_ingress_status)->toBe('exited') + ->and($server->last_status_check)->toBe('flux') + ->and($server->last_status_output)->toBe('Caddy container exited.') + ->and($server->last_status_checked_at)->not->toBeNull(); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->caddyIngressServerId === $server->id); +}); + +it('accepts flux status updates for non coolify managed containers', function () { + Config::set('flux.laravel_api_token', 'test-flux-token'); + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'wireguard_management_ip' => '100.64.0.5', + ]); + + $this + ->postJson('/api/v1/internal/flux/resource-status', [ + 'resource_type' => 'container', + 'host_id' => '100.64.0.5', + 'container_id' => 'external-container-id', + 'container_name' => 'external-container', + 'status' => 'running', + ], [ + 'Authorization' => 'Bearer test-flux-token', + ]) + ->assertSuccessful() + ->assertJsonPath('message', 'Resource status updated.'); + + expect(ContainerStatus::query() + ->where('container_id', 'external-container-id') + ->where('container_name', 'external-container') + ->where('status', 'running') + ->exists())->toBeTrue(); +}); + +it('rejects flux resource status http updates without the shared token', function () { + Config::set('flux.laravel_api_token', 'test-flux-token'); + + $this + ->postJson('/api/v1/internal/flux/resource-status', [ + 'resource_type' => 'application', + 'status' => 'running', + ]) + ->assertUnauthorized(); +}); + +it('accepts flux resource status http updates and stores them in the database', function () { + createSharedUserAndTeamTables(); + Config::set('flux.laravel_api_token', 'test-flux-token'); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'starting', + 'status_message' => 'Container starting.', + 'runtime_container_id' => 'old-container-id', + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $this + ->withToken('test-flux-token') + ->postJson('/api/v1/internal/flux/resource-status', [ + 'resource_type' => 'application', + 'host_id' => '100.64.0.5', + 'container_name' => 'coolify-v5-nginx-1', + 'container_id' => 'new-container-id', + 'status' => 'running', + 'status_message' => 'Status received from coold through flux.', + ]) + ->assertSuccessful() + ->assertJsonPath('message', 'Resource status updated.'); + + expect($application->refresh()->status)->toBe('running') + ->and($application->status_message)->toBe('Status received from coold through flux.') + ->and($application->runtime_container_id)->toBe('new-container-id'); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->applicationId === $application->id); +}); + +it('configures flux resource status updates for local http instead of redis', function () { + $configSource = file_get_contents(config_path('flux.php')); + + expect($configSource) + ->toContain('COOLIFY_FLUX_LARAVEL_API_TOKEN') + ->not->toContain('APP_KEY') + ->not->toContain('COOLIFY_FLUX_RESOURCE_STATUS_CHANNEL') + ->not->toContain('resource_status_channel'); +}); + +it('broadcasts v5 canvas resource updates when application state changes', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'nginx-container-id', + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $application->update([ + 'status' => 'exited', + 'status_message' => 'Container stopped.', + ]); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->applicationId === $application->id); +}); + +it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Cluster', + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold', 'ingress'], + ]); + + Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]); + + $server->update(['status' => 'unreachable']); + + Event::assertDispatched(V5ClusterUpdated::class, fn (V5ClusterUpdated $event) => $event->teamId === $team->id + && $event->clusterId === $cluster->id); + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->caddyIngressServerId === $server->id); +}); + +it('refreshes v5 application state from flux container inventory', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'capabilities' => ['coold'], + 'wireguard_management_ip' => '100.64.0.5', + ]); + $application = V5Application::query()->create([ + 'team_id' => $team->id, + 'project_id' => $project->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => $user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-1', + 'status' => 'running', + 'status_message' => 'Container started.', + 'runtime_container_id' => 'nginx-container-id', + 'canvas_x' => 0, + 'canvas_y' => 0, + ]); + + $this->mock(FluxClient::class, function (MockInterface $mock): void { + $mock->shouldReceive('listContainers') + ->once() + ->with('100.64.0.5') + ->andReturn([ + [ + 'id' => 'nginx-container-id', + 'name' => 'coolify-v5-nginx-1', + 'image' => 'docker.io/library/nginx:alpine', + 'state' => 'exited', + 'networks' => ['coolify-default-mesh'], + ], + ]); + }); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/refresh') + ->assertSuccessful() + ->assertJsonPath('applications.0.id', (string) $application->id) + ->assertJsonPath('applications.0.status', 'exited') + ->assertJsonPath('applications.0.statusMessage', 'Container state refreshed from coold.'); + + expect($application->refresh()->status)->toBe('exited') + ->and($application->status_message)->toBe('Container state refreshed from coold.'); +}); + +it('refreshes v5 caddy ingress state from flux container inventory', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + [$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production'); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-ingress-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'caddy_ingress_status' => 'running', + 'capabilities' => ['coold', 'ingress'], + 'wireguard_management_ip' => '100.64.0.6', + ]); + + Event::fake([V5CanvasResourceUpdated::class]); + + $this->mock(FluxClient::class, function (MockInterface $mock): void { + $mock->shouldReceive('listContainers') + ->once() + ->with('100.64.0.6') + ->andReturn([ + [ + 'id' => 'caddy-container-id', + 'name' => 'coolify-v5-caddy', + 'image' => 'docker.io/library/caddy:2-alpine', + 'state' => 'exited', + ], + ]); + }); + + $this + ->actingAs($user) + ->withSession([ + 'currentTeam' => $team, + 'v5.selectedProjectUuid' => $project->uuid, + 'v5.selectedEnvironmentUuid' => $environment->uuid, + ]) + ->postJson('/v5/applications/refresh') + ->assertSuccessful() + ->assertJsonPath('caddyIngresses.0.id', (string) $server->id) + ->assertJsonPath('caddyIngresses.0.status', 'exited'); + + expect($server->refresh()->status)->toBe('installed') + ->and($server->caddy_ingress_status)->toBe('exited') + ->and($server->last_status_check)->toBe('flux') + ->and($server->last_status_output)->toBe('Caddy ingress state refreshed from coold.'); + + Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id + && $event->caddyIngressServerId === $server->id); +}); + it('broadcasts v5 cluster updates when bootstrap state changes', function () { createSharedUserAndTeamTables(); Config::set('coold.coolify_cli_bin', '/tmp/coolify'); @@ -690,6 +2162,7 @@ it('adds a v5 server to a cluster for the current team', function () { ->assertJsonPath('cluster.servers.0.builderEnabled', true) ->assertJsonPath('cluster.servers.0.builderCapacity', 3) ->assertJsonPath('cluster.servers.0.builderCpuQuota', '200%') + ->assertJsonPath('cluster.servers.0.ingressEnabled', false) ->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'builder']) ->assertJsonPath('cluster.servers.0.wireguardListenPortOverride', 51821) ->assertJsonPath('cluster.servers.0.wireguardEndpointOverride', 'prod-01.example.com:51821') @@ -712,6 +2185,41 @@ it('adds a v5 server to a cluster for the current team', function () { ->toBe(['coold', 'builder']); }); +it('adds a v5 server with caddy ingress enabled', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $privateKey = createV5PrivateKey($team, 'Production SSH Key'); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + 'description' => null, + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->postJson("/v5/clusters/{$cluster->id}/servers", [ + 'name' => 'edge-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'private_key_id' => $privateKey->id, + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'ingress_enabled' => true, + ]) + ->assertCreated() + ->assertJsonPath('cluster.servers.0.ingressEnabled', true) + ->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']); + + $server = V5Server::query()->where('name', 'edge-01')->first(); + + expect($server->capabilities)->toBe(['coold', 'ingress']) + ->and($server->isIngress())->toBeTrue(); +}); + it('keeps added v5 server builder capacity when builder is disabled', function () { createSharedUserAndTeamTables(); @@ -1492,6 +3000,7 @@ it('updates editable v5 server builder details without changing networking', fun ->assertJsonPath('cluster.servers.0.builderEnabled', true) ->assertJsonPath('cluster.servers.0.builderCapacity', 5) ->assertJsonPath('cluster.servers.0.builderCpuQuota', '350%') + ->assertJsonPath('cluster.servers.0.ingressEnabled', false) ->assertJsonPath('cluster.servers.0.host', '203.0.113.10') ->assertJsonMissingPath('cluster.servers.0.sshUser') ->assertJsonMissingPath('cluster.servers.0.sshPort') @@ -1511,6 +3020,52 @@ it('updates editable v5 server builder details without changing networking', fun ->and($server->wireguard_endpoint_override)->toBe('prod-01.example.com:51821'); }); +it('updates editable v5 server caddy ingress capability independently from builder', function () { + createSharedUserAndTeamTables(); + + [$user, $team] = createV5UserWithTeam(); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Production Mesh', + 'description' => null, + ]); + $server = V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.20', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'added', + 'capabilities' => ['coold', 'builder'], + 'builder_enabled' => true, + 'builder_capacity' => 2, + 'builder_cpu_quota' => '200%', + ]); + + $this + ->actingAs($user) + ->withSession(['currentTeam' => $team]) + ->patchJson("/v5/clusters/{$cluster->id}/servers/{$server->id}", [ + 'builder_enabled' => false, + 'builder_capacity' => 2, + 'builder_cpu_quota' => '200%', + 'ingress_enabled' => true, + ]) + ->assertSuccessful() + ->assertJsonPath('cluster.servers.0.builderEnabled', false) + ->assertJsonPath('cluster.servers.0.ingressEnabled', true) + ->assertJsonPath('cluster.servers.0.capabilities', ['coold', 'ingress']); + + $server->refresh(); + + expect($server->capabilities)->toBe(['coold', 'ingress']) + ->and($server->builder_enabled)->toBeFalse() + ->and($server->isIngress())->toBeTrue(); +}); + it('keeps editable v5 server builder capacity when disabling builder', function () { createSharedUserAndTeamTables(); @@ -1914,22 +3469,40 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function () ->toContain('Dashboard') ->toContain("import { AppNavbar } from '@/components/app-navbar';") ->not->toContain('function csrfToken()') - ->not->toContain("import { csrfToken } from '@/lib/csrf';") + ->toContain("import { csrfToken } from '@/lib/csrf';") ->not->toContain("import { Button } from '@/components/ui/button';") ->not->toContain("fetch('/v5/clusters'") ->toContain('toContain('bg-background text-foreground') ->toContain('h-dvh overflow-hidden bg-background text-foreground') - ->toContain('flex h-full min-h-0 items-center justify-center overflow-hidden px-6 pt-16') + ->toContain('relative h-full min-h-0 overflow-hidden pt-16') + ->toContain('Add nginx') + ->toContain('Select nginx server') + ->toContain('selectedNginxServerId') + ->toContain('server_id: selectedNginxServerId || null') + ->toContain('Center') + ->toContain('Delete') + ->toContain("method: 'DELETE'") + ->toContain('removeApplication') + ->toContain('useEffect(() => {') + ->toContain('setApplications(settledResources.applications);') + ->toContain('centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);') + ->toContain('Caddy ingress') + ->toContain('persistCaddyIngressPosition') + ->toContain('startIngressDrag') + ->toContain('fetch(`/v5/caddy-ingresses/${ingress.id}/position`') + ->toContain("fetch('/v5/applications/nginx'") + ->toContain('nginxServers = []') + ->toContain('fetch(`/v5/applications/${application.id}/position`') ->not->toContain('not->toContain('h-[calc(100dvh-4rem)]') ->not->toContain('flex h-dvh flex-col overflow-hidden bg-background text-foreground') - ->toContain('This is where the magic happens.') + ->toContain('No applications on this canvas yet.') ->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';") ->not->toContain("fetch('/v5/selection'"); expect($navbar) - ->toContain("import { Link, usePage } from '@inertiajs/react';") + ->toContain("import { Link, router, usePage } from '@inertiajs/react';") ->toContain("import { cn } from '@/lib/utils';") ->toContain("import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';") ->toContain("import { csrfToken } from '@/lib/csrf';") @@ -1939,7 +3512,7 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function () ->toContain('toContain('className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background"') ->not->toContain('className="sticky top-0 z-40 shrink-0 border-b border-border bg-background"') - ->toContain('bg-muted/40') + ->toContain('hover:bg-muted') ->toContain('text-muted-foreground') ->toContain('SelectGroup') ->toContain('variant="ghost"') @@ -1971,6 +3544,10 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function () ->toContain('Dashboard') ->not->toContain('Home') ->toContain("fetch('/v5/selection'") + ->toContain('router.reload({') + ->toContain("only: ['applications', 'selectedProjectUuid', 'selectedEnvironmentUuid']") + ->toContain('void persistSelection(nextProjectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);') + ->toContain('void persistSelection(projectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);') ->toContain("'X-CSRF-TOKEN': csrfToken()") ->not->toContain('isMobileMenuOpen') ->not->toContain('setIsMobileMenuOpen') @@ -2176,11 +3753,14 @@ it('defines the v5 cluster management page and create cluster form', function () ->toContain('flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6') ->toContain('flex w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6') ->toContain('rounded-lg border border-border bg-card p-4') + ->toContain('flex items-start justify-between gap-3') + ->toContain('min-w-0 flex-1') + ->toContain('flex shrink-0 items-center justify-end gap-2 sm:flex-wrap') ->toContain('aria-label="Select a cluster"') ->toContain('setSelectedClusterId(value)') ->not->toContain('flex max-h-80 flex-col rounded-lg border border-border bg-card lg:max-h-none lg:min-h-0') ->toContain('overflow-visible rounded-lg border border-border bg-card lg:min-h-0 lg:overflow-y-auto') - ->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end') + ->not->toContain('flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end') ->toContain('mt-4 grid grid-cols-1 gap-3 text-xs sm:grid-cols-2') ->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)]') ->not->toContain('lg:grid-cols-[20rem_minmax(0,1fr)_22rem]') @@ -2315,6 +3895,14 @@ it('uses the requested shadcn preset configuration for v5', function () { ->and($css)->toContain('button:not(:disabled)'); }); +it('sizes the v5 app root with the dynamic mobile viewport', function () { + $css = file_get_contents(resource_path('css/v5/app.css')); + + expect($css) + ->toContain('min-height: 100dvh;') + ->not->toContain('min-height: 100vh;'); +}); + it('selects a shared team when the session has no current team', function () { $this->withoutVite(); fakeFluxHealth(); @@ -2449,15 +4037,15 @@ it('syncs dev Lima VMs into v5 clusters and servers', function () { '--cluster' => 'Development-Lima', '--builder-capacity' => 2, '--server' => [ - 'coold-dev|host.docker.internal|developer|61332', - 'coold-dev-2|host.docker.internal|developer|61379', + 'coold-dev|host.docker.internal|developer|61332|100.64.0.1', + 'coold-dev-2|host.docker.internal|developer|61379|100.64.0.2', ], ]); expect($exitCode)->toBe(0) ->and(Cluster::query()->where('name', 'Development-Lima')->count())->toBe(1) - ->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue() - ->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue(); + ->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->where('ssh_port', 61332)->where('private_key_id', $privateKey->id)->exists())->toBeTrue() + ->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->where('ssh_port', 61379)->where('private_key_id', $privateKey->id)->exists())->toBeTrue(); }); it('updates legacy dev Lima hostnames to Docker reachable SSH endpoints', function () { @@ -2519,11 +4107,45 @@ it('seeds dev Lima VMs into v5 clusters and servers idempotently', function () { ->and(V5Server::query()->count())->toBe(2) ->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60001)->exists())->toBeTrue() ->and(V5Server::query()->where('name', 'coold-dev-2')->where('host', 'host.docker.internal')->where('ssh_user', get_current_user())->where('ssh_port', 60002)->exists())->toBeTrue() + ->and(V5Server::query()->where('name', 'coold-dev')->where('node_address', '100.64.0.1')->where('wireguard_management_ip', '100.64.0.1')->exists())->toBeTrue() + ->and(V5Server::query()->where('name', 'coold-dev-2')->where('node_address', '100.64.0.2')->where('wireguard_management_ip', '100.64.0.2')->exists())->toBeTrue() ->and(V5Server::query()->where('status', 'installed')->count())->toBe(2) ->and(V5Server::query()->where('builder_enabled', true)->where('builder_capacity', 2)->count())->toBe(2) ->and(V5Server::query()->where('cluster_id', $cluster->id)->count())->toBe(2); }); +it('seeds dev Lima VMs by updating existing named servers', function () { + createSharedUserAndTeamTables(); + [$user, $team] = createV5UserWithTeam(); + createV5PrivateKey($team, 'Dev Lima Key'); + $cluster = Cluster::query()->create([ + 'team_id' => $team->id, + 'created_by_user_id' => $user->id, + 'name' => 'Development-Lima', + 'description' => 'Local Lima development cluster managed by scripts/dev.sh.', + ]); + + V5Server::query()->create([ + 'team_id' => $team->id, + 'cluster_id' => $cluster->id, + 'created_by_user_id' => $user->id, + 'name' => 'coold-dev', + 'host' => 'old-host.local', + 'ssh_user' => 'developer', + 'ssh_port' => 22, + 'status' => 'installed', + 'builder_enabled' => false, + 'builder_capacity' => 0, + 'last_bootstrapped_at' => now()->subDay(), + ]); + + (new V5DevLimaSeeder)->run(); + + expect(V5Server::query()->where('name', 'coold-dev')->count())->toBe(1) + ->and(V5Server::query()->where('name', 'coold-dev')->where('host', 'host.docker.internal')->where('ssh_port', 60001)->exists())->toBeTrue() + ->and(V5Server::query()->count())->toBe(2); +}); + function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void { app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) { @@ -2630,6 +4252,7 @@ function createSharedUserAndTeamTables(): void $table->string('ssh_user'); $table->unsignedInteger('ssh_port'); $table->string('status')->default('installed'); + $table->string('caddy_ingress_status')->nullable(); $table->json('capabilities')->nullable(); $table->boolean('builder_enabled')->default(false); $table->unsignedInteger('builder_capacity')->default(0); @@ -2640,6 +4263,8 @@ function createSharedUserAndTeamTables(): void $table->string('wireguard_management_ip')->nullable(); $table->string('wireguard_public_key')->nullable(); $table->json('container_subnets')->nullable(); + $table->integer('canvas_x')->nullable(); + $table->integer('canvas_y')->nullable(); $table->timestamp('last_bootstrapped_at')->nullable(); $table->string('last_bootstrap_action')->nullable(); $table->string('last_bootstrap_status')->nullable(); @@ -2651,6 +4276,68 @@ function createSharedUserAndTeamTables(): void $table->timestamps(); }); + Schema::create('v5_container_statuses', function ($table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('server_id'); + $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']); + }); + + Schema::create('v5_applications', function ($table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('project_id'); + $table->foreignId('environment_id'); + $table->foreignId('server_id')->nullable(); + $table->foreignId('created_by_user_id'); + $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(); + }); + + Schema::create('v5_resource_connections', function ($table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('project_id'); + $table->foreignId('environment_id'); + $table->string('resource_one_type'); + $table->unsignedBigInteger('resource_one_id'); + $table->string('resource_two_type'); + $table->unsignedBigInteger('resource_two_id'); + $table->string('resource_pair_key'); + $table->foreignId('created_by_user_id'); + $table->timestamps(); + + $table->unique(['team_id', 'resource_pair_key']); + }); + + Schema::create('v5_resource_connection_rules', function ($table) { + $table->id(); + $table->foreignId('connection_id'); + $table->string('source_resource_type'); + $table->unsignedBigInteger('source_resource_id'); + $table->string('target_resource_type'); + $table->unsignedBigInteger('target_resource_id'); + $table->string('protocol')->default('tcp'); + $table->unsignedSmallInteger('port'); + $table->timestamps(); + }); + Schema::create('team_user', function ($table) { $table->id(); $table->foreignId('team_id'); @@ -2714,11 +4401,11 @@ function createV5PrivateKey(Team $team, string $name): PrivateKey /** * @return array{0: User, 1: Team} */ -function createV5UserWithTeam(): array +function createV5UserWithTeam(string $email = 'margaret@example.com'): array { $user = User::withoutEvents(fn () => User::query()->create([ 'name' => 'Margaret Hamilton', - 'email' => 'margaret@example.com', + 'email' => $email, 'email_verified_at' => now(), 'password' => 'password', ])); @@ -2732,3 +4419,15 @@ function createV5UserWithTeam(): array return [$user, $team]; } + +it('configures v5 dev lima host resolver for coolify internal dns', function () { + $script = file_get_contents(base_path('scripts/coold-vm.sh')); + + expect($script) + ->toContain('configure_system_resolved') + ->toContain('ensure_mesh_dns_anchor') + ->toContain('coolify-v5-mesh-dns-anchor') + ->toContain('resolvectl dns podman1 "$CONTAINER_GATEWAY"') + ->toContain("resolvectl domain podman1 '~coolify.internal'") + ->toContain('resolvectl default-route podman1 false'); +}); diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index 49a6b881f..e0b2bf8b7 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -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); diff --git a/tests/Unit/V5/CaddyIngressConfigurationTest.php b/tests/Unit/V5/CaddyIngressConfigurationTest.php new file mode 100644 index 000000000..0808caac3 --- /dev/null +++ b/tests/Unit/V5/CaddyIngressConfigurationTest.php @@ -0,0 +1,149 @@ +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'); + }); +}); diff --git a/tests/Unit/V5/JavaScript/canvas-collision.test.ts b/tests/Unit/V5/JavaScript/canvas-collision.test.ts new file mode 100644 index 000000000..6622648b0 --- /dev/null +++ b/tests/Unit/V5/JavaScript/canvas-collision.test.ts @@ -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 }, + ], + ); +}); diff --git a/tests/Unit/V5/NginxApplicationDeploymentTest.php b/tests/Unit/V5/NginxApplicationDeploymentTest.php new file mode 100644 index 000000000..61b0a154e --- /dev/null +++ b/tests/Unit/V5/NginxApplicationDeploymentTest.php @@ -0,0 +1,32 @@ + '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'); +});