diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 98c0e0bc64..5eaf8d7655 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -7,6 +7,7 @@ use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Services\ProxyPortParser; +use Illuminate\Support\Collection; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Spatie\Activitylog\Models\Activity; @@ -40,12 +41,35 @@ class StartProxy ProxyStatusChangedUI::dispatch($server->team_id); } + SaveProxyConfiguration::run($server, $configuration); + $server->markProxyConfigurationApplied($configuration); + $commands = self::startCommands($server); + + if ($async) { + $server->markProxyTransitionStarted(); + + return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id, queue: deployment_queue()); + } else { + instant_remote_process($commands, $server); + + $server->proxy->set('type', $proxyType); + $server->save(); + ProxyStatusChanged::dispatch($server->id); + + return 'OK'; + } + } + + /** + * Commands that start the proxy. They first remove an existing proxy container. + * RestartProxyJob uses the same commands after its stop phase. + */ + public static function startCommands(Server $server): Collection + { $commands = collect([]); $proxy_path = $server->proxyPath(); // Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255). $compose_file = rtrim($proxy_path, '/').'/docker-compose.yml'; - SaveProxyConfiguration::run($server, $configuration); - $server->markProxyConfigurationApplied($configuration); if ($server->isSwarmManager()) { $commands = $commands->merge([ @@ -79,7 +103,7 @@ class StartProxy " echo 'Successfully stopped and removed existing coolify-proxy.'", 'fi', ]); - if ($proxyType !== ProxyTypes::TRAEFIK->value) { + if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) { // The sidecar belongs to the Traefik compose project, so --remove-orphans of another proxy keeps it. $commands->push('docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true'); } @@ -93,16 +117,6 @@ class StartProxy $commands = $commands->merge(connectProxyToNetworks($server)); } - if ($async) { - return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id, queue: deployment_queue()); - } else { - instant_remote_process($commands, $server); - - $server->proxy->set('type', $proxyType); - $server->save(); - ProxyStatusChanged::dispatch($server->id); - - return 'OK'; - } + return $commands; } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 7405b1346c..0107e4bc1d 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -817,7 +817,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function updateProxyStatus() { // If proxy is not found, start it - if ($this->server->isProxyShouldRun()) { + if ($this->server->isProxyShouldRun() && ! $this->server->isProxyTransitionInProgress()) { if ($this->foundProxy === false) { try { if (CheckProxy::run($this->server)) { diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index c99cf9d9eb..5008ff4ce9 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; +use App\Actions\Proxy\StartProxy; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Services\ProxyDashboardCacheService; @@ -45,6 +46,8 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue } ProxyPortParser::fromConfiguration($configuration); + $this->server->markProxyTransitionStarted(); + // Set status to restarting $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; @@ -68,8 +71,12 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); } catch (\InvalidArgumentException $e) { + $this->server->markProxyTransitionFinished(); + return handleError($e); } catch (\Throwable $e) { + $this->server->markProxyTransitionFinished(); + // Set error status $this->server->proxy->status = 'error'; $this->server->save(); @@ -91,9 +98,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue private function buildRestartCommands(string $configuration): array { $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; - $proxy_path = $this->server->proxyPath(); - // Absolute paths: a non-root SSH user may not be able to enter the proxy directory (#4255). - $compose_file = rtrim($proxy_path, '/').'/docker-compose.yml'; $stopTimeout = 30; SaveProxyConfiguration::run($this->server, $configuration); @@ -127,34 +131,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue ]); // === START PHASE === - if ($this->server->isSwarmManager()) { - $commands = $commands->merge([ - "echo 'Starting proxy (Swarm mode)...'", - "mkdir -p $proxy_path/dynamic", - "echo 'Creating required Docker Compose file.'", - "echo 'Starting coolify-proxy.'", - "docker stack deploy --detach=true -c $compose_file coolify-proxy", - "echo 'Successfully started coolify-proxy.'", - ]); - } else { - $caddyfile = 'import /dynamic/*.caddy'; - $commands = $commands->merge([ - "echo 'Starting proxy...'", - "mkdir -p $proxy_path/dynamic", - "echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null", - "echo 'Creating required Docker Compose file.'", - "echo 'Pulling docker image.'", - "docker compose -f $compose_file pull", - ]); - // Ensure required networks exist BEFORE docker compose up - $commands = $commands->merge(ensureProxyNetworksExist($this->server)); - $commands = $commands->merge([ - "echo 'Starting coolify-proxy.'", - "docker compose -f $compose_file up -d --wait --remove-orphans", - "echo 'Successfully started coolify-proxy.'", - ]); - $commands = $commands->merge(connectProxyToNetworks($this->server)); - } + $commands = $commands->merge(StartProxy::startCommands($this->server)); return $commands->toArray(); } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 0ec1ce606f..1d449cc23e 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -77,7 +77,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue $this->checkLogDrainContainer(); } - if ($this->server->proxySet() && ! $this->server->proxy->force_stop) { + if ($this->server->proxySet() && ! $this->server->proxy->force_stop && ! $this->server->isProxyTransitionInProgress()) { $this->server->proxyType(); $foundProxyContainer = $this->containers->filter(function ($value, $key) { if ($this->server->isSwarm()) { diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 74851cee52..73b43d551c 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -24,6 +24,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if (is_null($server)) { return; } + $server->markProxyTransitionFinished(); $proxyContainerName = 'coolify-proxy'; $status = getContainerStatus($server, $proxyContainerName); $server->proxy->set('status', $status); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 1a64d0a15e..4e742df4e5 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -251,6 +251,7 @@ class Navbar extends Component { $this->server->refresh(); $this->server->load('settings'); + $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; $this->dispatchProxyConfigurationState(); } diff --git a/app/Models/Server.php b/app/Models/Server.php index e1eac85d6a..145edd55cd 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2124,6 +2124,30 @@ $siteAddress { return true; } + /** + * Marks a proxy start or restart as running, so status checks do not start a second proxy meanwhile. + * ProxyStatusChangedNotification clears the mark when the start or restart finishes. + */ + public function markProxyTransitionStarted(): void + { + Cache::put($this->proxyTransitionCacheKey(), true, now()->addMinutes(5)); + } + + public function markProxyTransitionFinished(): void + { + Cache::forget($this->proxyTransitionCacheKey()); + } + + public function isProxyTransitionInProgress(): bool + { + return Cache::has($this->proxyTransitionCacheKey()); + } + + private function proxyTransitionCacheKey(): string + { + return "proxy-transition:{$this->uuid}"; + } + public function skipServer() { if ($this->hasPlaceholderIp()) { diff --git a/tests/Feature/Proxy/MaintenanceModeTest.php b/tests/Feature/Proxy/MaintenanceModeTest.php index 44d8ba6d6e..5a16cec8db 100644 --- a/tests/Feature/Proxy/MaintenanceModeTest.php +++ b/tests/Feature/Proxy/MaintenanceModeTest.php @@ -3,6 +3,7 @@ use App\Enums\ProxyTypes; use App\Events\ProxyStatusChanged; use App\Events\ProxyStatusChangedUI; +use App\Jobs\RestartProxyJob; use App\Livewire\Project\Shared\Maintenance; use App\Models\Application; use App\Models\Environment; @@ -206,6 +207,22 @@ it('writes the maintenance blocks into the base Caddyfile on Caddy servers', fun ->toContain("@coolify-maintenance-{$application->uuid}-0 path /*"); }); +it('keeps the maintenance blocks and removes the Traefik logrotate sidecar when Caddy restarts', function () { + $this->server->proxy->type = ProxyTypes::CADDY->value; + $this->server->save(); + $application = maintenanceApplication(['is_maintenance_enabled' => true]); + $server = maintenanceServer(); + + $method = new ReflectionMethod(RestartProxyJob::class, 'buildRestartCommands'); + $commands = $method->invoke(new RestartProxyJob($server), "services:\n caddy:\n container_name: coolify-proxy\n image: lucaslorentz/caddy-docker-proxy:2.13-alpine\n"); + $caddyfile = base64_encode($server->caddyBaseCaddyfile()); + + expect(base64_decode($caddyfile))->toContain("@coolify-maintenance-{$application->uuid}-0 path /*") + ->and($commands) + ->toContain("echo '{$caddyfile}' | base64 -d | tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null") + ->toContain('docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true'); +}); + it('keeps the page container for maintenance when a redirect URL replaces the catch-all page', function () { maintenanceApplication(['is_maintenance_enabled' => true]); $server = maintenanceServer(); diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php index 61e6dfe78c..0ae7a420cd 100644 --- a/tests/Feature/PushServerUpdateJobOptimizationTest.php +++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php @@ -1,13 +1,18 @@ fresh()->proxy->get('status'))->toBe('running'); }); +it('does not start a second proxy while a proxy restart runs', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + $server->proxy->set('type', 'TRAEFIK'); + $server->proxy->set('status', 'restarting'); + $server->save(); + $server->markProxyTransitionStarted(); + + CheckProxy::shouldNotRun(); + StartProxy::shouldNotRun(); + Event::fake([ServiceChecked::class]); + + (new PushServerUpdateJob($server, ['containers' => [], 'filesystem_usage_root' => ['used_percentage' => 10]]))->handle(); + + expect($server->fresh()->proxy->get('status'))->toBe('restarting'); +}); + +it('starts a missing proxy when no proxy restart runs', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + $server->proxy->set('type', 'TRAEFIK'); + $server->save(); + + CheckProxy::shouldRun()->once()->andReturn(true); + StartProxy::shouldRun()->once(); + Event::fake([ServiceChecked::class]); + + (new PushServerUpdateJob($server, ['containers' => [], 'filesystem_usage_root' => ['used_percentage' => 10]]))->handle(); +}); + +it('ends the proxy restart when the proxy status listener runs', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->markProxyTransitionStarted(); + Process::fake(); + + (new ProxyStatusChangedNotification)->handle(new ProxyStatusChanged($server->id)); + + expect($server->isProxyTransitionInProgress())->toBeFalse(); +}); + it('uses default queue for PushServerUpdateJob', function () { $team = Team::factory()->create(); $server = Server::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ServerNavbarStatusVisibilityTest.php b/tests/Feature/ServerNavbarStatusVisibilityTest.php index fddef90107..726f295e45 100644 --- a/tests/Feature/ServerNavbarStatusVisibilityTest.php +++ b/tests/Feature/ServerNavbarStatusVisibilityTest.php @@ -133,3 +133,19 @@ it('shows the Traefik dashboard link without proxy actions to members who cannot ->assertSee('Traefik Dashboard') ->assertDontSee('Refresh Proxy Status'); }); + +it('shows the current proxy status after a background status update', function () { + [$server] = makeNavbarServer(isFunctional: true); + $server->forceFill(['proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'starting']])->save(); + + $component = Livewire::test('server.navbar', ['server' => $server->fresh()]) + ->assertSet('proxyStatus', 'starting'); + + // Sentinel or the server check job marks the proxy as running without a UI broadcast. + $server->proxy->set('status', 'running'); + $server->save(); + + $component->call('refreshAgentStatus') + ->assertSet('proxyStatus', 'running') + ->assertDontSee('Starting'); +});