mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(proxy): keep proxy status current and stop auto-start during restarts
- Refresh the navbar proxy status on the 30 s poll - Skip Sentinel and server check auto-start while a proxy start or restart runs - Use the StartProxy commands in RestartProxyJob, so Caddy restarts keep the maintenance blocks and remove the Traefik logrotate sidecar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e964c5de2d
commit
7e612422b6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -251,6 +251,7 @@ class Navbar extends Component
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->server->load('settings');
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
$this->dispatchProxyConfigurationState();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ProxyStatusChanged;
|
||||
use App\Events\ServiceChecked;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Listeners\ProxyStatusChangedNotification;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -173,6 +178,49 @@ it('marks a stale proxy status as running when sentinel reports a running proxy'
|
||||
expect($server->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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user