diff --git a/AGENTS.md b/AGENTS.md index f9f8ca563a..6aaa0a8604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ For UI/UX design specifications, principles, and visual standards, consult the l ## Development Environment -Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio. +Docker Compose-based dev setup with services: coolify (app, which also runs Reverb WebSockets and the terminal server), postgres, redis, vite, testing-host, mailpit, minio. ```bash # Start dev environment (uses docker-compose.dev.yml) @@ -127,7 +127,7 @@ Because the "server" and the test share one PHP process, they share the phpunit ### Backend Structure (app/) - **Actions/** — Domain actions organized by area (Application, Database, Docker, Proxy, Server, Service, Shared, Stripe, User, CoolifyTask, Fortify). Uses `lorisleiva/laravel-actions` with `AsAction` trait — actions can be called as objects, dispatched as jobs, or used as controllers. -- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Soketi. +- **Livewire/** — All UI components (Livewire 3). Pages organized by domain: Server, Project, Settings, Security, Notifications, Terminal, Subscription, SharedVariables. This is the primary UI layer — no traditional Blade controllers. Components listen to private team channels for real-time status updates via Laravel Reverb. - **Jobs/** — Queue jobs for deployments (`ApplicationDeploymentJob`), backups, Docker cleanup, server management, proxy configuration. Uses Redis queue with Horizon for monitoring. - **Models/** — Eloquent models extending `BaseModel` which provides auto-CUID2 UUID generation. Key models: `Server`, `Application`, `Service`, `Project`, `Environment`, `Team`, plus standalone database models (`StandalonePostgresql`, `StandaloneMysql`, etc.). Common traits: `HasConfiguration`, `HasMetrics`, `HasSafeStringAttribute`, `ClearsGlobalSearchCache`. - **Services/** — Business logic services (ConfigurationGenerator, DockerImageParser, ContainerStatusAggregator, HetznerService, etc.). Use Services for complex orchestration; use Actions for single-purpose domain operations. @@ -155,7 +155,8 @@ Because the "server" and the test share one PHP process, they share the phpunit - Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. ### Event Broadcasting -- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) +- Laravel Reverb WebSocket server for real-time updates (port 6001) and a Node terminal WebSocket server (port 6002), both run inside the `coolify` container as s6 services +- Server-side broadcasts use `PUSHER_BACKEND_HOST`/`PUSHER_BACKEND_PORT` (defaults `127.0.0.1:6001`); `PUSHER_HOST`/`PUSHER_PORT` are browser-facing only - Status change events: `ApplicationStatusChanged`, `ServiceStatusChanged`, `DatabaseStatusChanged`, `ProxyStatusChanged` - Livewire components subscribe to private team channels via `getListeners()` diff --git a/TECH_STACK.md b/TECH_STACK.md index 6a779eb293..fa100856c0 100644 --- a/TECH_STACK.md +++ b/TECH_STACK.md @@ -13,7 +13,7 @@ - Laravel 11 (PHP Framework) - PostgreSQL 15 (Database) - Redis 7 (Caching & Real-time features) -- Soketi (WebSocket Server) +- Laravel Reverb (WebSocket Server) ## DevOps & Infrastructure diff --git a/config/broadcasting.php b/config/broadcasting.php index 8348c1a052..52e896b6be 100644 --- a/config/broadcasting.php +++ b/config/broadcasting.php @@ -1,5 +1,15 @@ env('PUSHER_APP_SECRET', 'coolify'), 'app_id' => env('PUSHER_APP_ID', 'coolify'), 'options' => [ - 'host' => env('PUSHER_HOST', 'coolify'), + 'host' => $backendHost, 'port' => env('PUSHER_BACKEND_PORT', 6001), - 'scheme' => env('PUSHER_SCHEME', 'http'), + 'scheme' => env('PUSHER_BACKEND_SCHEME', 'http'), 'encrypted' => true, - 'useTLS' => env('PUSHER_SCHEME', 'http') === 'https', + 'useTLS' => env('PUSHER_BACKEND_SCHEME', 'http') === 'https', 'path' => '', ], 'client_options' => [ @@ -48,17 +58,19 @@ return [ ], ], + // Legacy connection name (BROADCAST_DRIVER=pusher). Reverb speaks the Pusher + // protocol, so this keeps pointing at the bundled server like it did before. 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY', 'coolify'), 'secret' => env('PUSHER_APP_SECRET', 'coolify'), 'app_id' => env('PUSHER_APP_ID', 'coolify'), 'options' => [ - 'host' => env('PUSHER_HOST'), - 'port' => env('PUSHER_PORT', 443), - 'scheme' => env('PUSHER_SCHEME', 'https'), + 'host' => $backendHost, + 'port' => env('PUSHER_BACKEND_PORT', 6001), + 'scheme' => env('PUSHER_BACKEND_SCHEME', 'http'), 'encrypted' => true, - 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + 'useTLS' => env('PUSHER_BACKEND_SCHEME', 'http') === 'https', ], 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8764aa0563..bb09c08e33 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,7 +32,7 @@ services: - "6001" - "${TERMINAL_PORT:-6002}" healthcheck: - test: curl --fail http://127.0.0.1:8080/api/health || exit 1 + test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1 interval: 5s retries: 24 start_period: 1m diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index c10edfb50c..503b80dc6b 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -58,7 +58,7 @@ services: - "6001" - "${TERMINAL_PORT:-6002}" healthcheck: - test: curl --fail http://localhost:8080/api/health || exit 1 + test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1 interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 8764aa0563..bb09c08e33 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -32,7 +32,7 @@ services: - "6001" - "${TERMINAL_PORT:-6002}" healthcheck: - test: curl --fail http://127.0.0.1:8080/api/health || exit 1 + test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1 interval: 5s retries: 24 start_period: 1m diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 10898ae3c3..6abe677fc9 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -57,7 +57,7 @@ services: - "6001" - "${TERMINAL_PORT:-6002}" healthcheck: - test: curl --fail http://localhost:8080/api/health || exit 1 + test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1 interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 0e441ad380..42b588fe8d 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -152,6 +152,10 @@ 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)" update_env_var "PUSHER_BACKEND_PORT" "6001" +# The realtime container no longer exists; point older installs at the bundled Reverb server +if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then + set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1" +fi log "Environment variables check complete" echo " Done." @@ -249,7 +253,8 @@ nohup bash -c " } # Stop and remove containers - for container in coolify coolify-db coolify-redis; do + # coolify-realtime is kept for upgrades from versions that still ran the separate realtime container + for container in coolify coolify-db coolify-redis coolify-realtime; do if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then log \"Stopping container: \${container}\" docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index c3cdf00593..c2bcabc3f7 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -152,6 +152,10 @@ 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)" update_env_var "PUSHER_BACKEND_PORT" "6001" +# The realtime container no longer exists; point older installs at the bundled Reverb server +if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then + set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1" +fi log "Environment variables check complete" echo " Done." @@ -262,7 +266,8 @@ nohup bash -c " } # Stop and remove containers - for container in coolify coolify-db coolify-redis; do + # coolify-realtime is kept for upgrades from versions that still ran the separate realtime container + for container in coolify coolify-db coolify-redis coolify-realtime; do if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then log \"Stopping container: \${container}\" docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php index a9ed6cdf72..9449313b45 100644 --- a/tests/Feature/ReverbAndTerminalPackagingTest.php +++ b/tests/Feature/ReverbAndTerminalPackagingTest.php @@ -1,5 +1,7 @@ toContain('"laravel/reverb"') @@ -9,11 +11,84 @@ it('uses Reverb as the first-party broadcast server', function () { ->toContain("'key' => env('PUSHER_APP_KEY', 'coolify')") ->toContain("'secret' => env('PUSHER_APP_SECRET', 'coolify')") ->toContain("'app_id' => env('PUSHER_APP_ID', 'coolify')") - ->toContain("'host' => env('PUSHER_HOST', 'coolify')") + ->toContain("'host' => \$backendHost") ->toContain("'port' => env('PUSHER_BACKEND_PORT', 6001)") + ->toContain("'scheme' => env('PUSHER_BACKEND_SCHEME', 'http')") ->and(file_exists(config_path('reverb.php')))->toBeTrue(); }); +it('does not send server-side broadcasts to the browser-facing Pusher host', function () { + $reverbConnection = Str::between(file_get_contents(config_path('broadcasting.php')), "'reverb' => [", "'pusher' => ["); + + expect($reverbConnection) + ->not->toContain('PUSHER_HOST') + ->not->toContain("env('PUSHER_SCHEME'"); +}); + +it('keeps working with environment variables from installs that used the realtime container', function (array $environment, string $connection, string $expectedHost) { + $previous = []; + + foreach ($environment as $key => $value) { + $previous[$key] = $_SERVER[$key] ?? null; + $_SERVER[$key] = $value; + } + + try { + $broadcasting = require config_path('broadcasting.php'); + } finally { + foreach ($previous as $key => $value) { + if ($value === null) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } + } + } + + $options = $broadcasting['connections'][$connection]['options']; + + expect($options['host'])->toBe($expectedHost) + ->and($options['port'])->toBe(6001) + ->and($options['scheme'])->toBe('http') + ->and($options['useTLS'])->toBeFalse(); +})->with([ + 'legacy realtime backend host' => [['PUSHER_BACKEND_HOST' => 'coolify-realtime'], 'reverb', '127.0.0.1'], + 'legacy pusher driver' => [['BROADCAST_DRIVER' => 'pusher', 'PUSHER_BACKEND_HOST' => 'coolify-realtime'], 'pusher', '127.0.0.1'], + 'public browser host and scheme' => [['PUSHER_HOST' => 'coolify.example.com', 'PUSHER_SCHEME' => 'https', 'PUSHER_PORT' => '443'], 'reverb', '127.0.0.1'], + 'custom backend host' => [['PUSHER_BACKEND_HOST' => 'coolify'], 'reverb', 'coolify'], +]); + +it('rewrites the legacy realtime backend host during upgrades', function (string $script) { + expect(file_get_contents(base_path($script))) + ->toContain('if grep -q \'^PUSHER_BACKEND_HOST=coolify-realtime$\' "$ENV_FILE"; then') + ->toContain('set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"'); +})->with([ + 'upgrade script' => ['scripts/upgrade.sh'], + 'nightly upgrade script' => ['other/nightly/upgrade.sh'], +]); + +it('includes Reverb but not the terminal server in the Coolify container healthcheck', function (string $composeFile) { + $composeContents = file_get_contents(base_path($composeFile)); + + expect($composeContents) + ->toContain('/api/health && curl --fail http://') + ->toContain(':${PUSHER_BACKEND_PORT:-6001}/up || exit 1') + ->not->toContain('6002/ready'); +})->with([ + 'production compose' => ['docker-compose.prod.yml'], + 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'], + 'windows compose' => ['docker-compose.windows.yml'], + 'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'], +]); + +it('removes the legacy realtime container during upgrades', function (string $script) { + expect(file_get_contents(base_path($script))) + ->toContain('for container in coolify coolify-db coolify-redis coolify-realtime; do'); +})->with([ + 'upgrade script' => ['scripts/upgrade.sh'], + 'nightly upgrade script' => ['other/nightly/upgrade.sh'], +]); + it('runs Reverb and terminal websocket services inside the Coolify containers', function (string $dockerfile, string $dependencyService) { $dockerfileContents = file_get_contents(base_path($dockerfile)); @@ -48,7 +123,7 @@ it('removes the dedicated realtime service from bundled compose files', function ->not->toContain('SOKETI_DEFAULT_APP_ID') ->toContain('6001') ->toContain('6002') - ->not->toContain('REVERB_'); + ->not->toMatch('/REVERB_(?!PORT\b)/'); if ($hasRuntimeEnvironment) { expect($composeContents)->toContain('PUSHER_BACKEND_PORT'); @@ -108,7 +183,7 @@ it('uses Pusher environment keys for self-hosted Reverb compatibility', function foreach ($files as $file) { expect(file_get_contents(base_path($file))) ->toContain('PUSHER_') - ->not->toContain('REVERB_'); + ->not->toMatch('/REVERB_(?!PORT\b)/'); } });