diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 9fa18e3dc..46d0a1a84 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -292,13 +292,20 @@ class OtherController extends Controller #[OA\Get( summary: 'Healthcheck', - description: 'Healthcheck endpoint.', + description: 'Healthcheck endpoint. Includes the running Coolify version in the X-Coolify-Version header.', path: '/health', operationId: 'healthcheck', responses: [ new OA\Response( response: 200, description: 'Healthcheck endpoint.', + headers: [ + new OA\Header( + header: 'X-Coolify-Version', + description: 'Currently running Coolify version.', + schema: new OA\Schema(type: 'string', example: '4.3.1'), + ), + ], content: new OA\MediaType( mediaType: 'text/html', schema: new OA\Schema(type: 'string'), @@ -316,6 +323,6 @@ class OtherController extends Controller )] public function healthcheck(Request $request) { - return 'OK'; + return response('OK')->header('X-Coolify-Version', (string) config('constants.coolify.version')); } } diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 0ccb06a08..aad02c780 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Actions\Server\UpdateCoolify; use App\Models\InstanceSettings; use App\Models\Server; +use App\Services\CoolifyUpgradeStatus; use Livewire\Component; class Upgrade extends Component @@ -69,7 +70,13 @@ class Upgrade extends Component return; } $this->updateInProgress = true; - UpdateCoolify::run(manual_update: true); + dispatch(function () { + try { + UpdateCoolify::run(manual_update: true); + } catch (\Throwable $e) { + report($e); + } + })->afterResponse(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -100,45 +107,10 @@ class Upgrade extends Component return ['status' => 'none']; } - if (empty($content)) { - return ['status' => 'none']; - } - - $parts = explode('|', $content); - if (count($parts) < 3) { - return ['status' => 'none']; - } - - [$step, $message, $timestamp] = $parts; - - // Check if status is stale (older than 10 minutes) - try { - $statusTime = new \DateTime($timestamp); - $now = new \DateTime; - $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60; - - if ($diffMinutes > 10) { - return ['status' => 'none']; - } - } catch (\Throwable $e) { - return ['status' => 'none']; - } - - if ($step === 'error') { - return [ - 'status' => 'error', - 'step' => 0, - 'message' => $message, - ]; - } - - $stepInt = (int) $step; - $status = $stepInt >= 6 ? 'complete' : 'in_progress'; - - return [ - 'status' => $status, - 'step' => $stepInt, - 'message' => $message, - ]; + return CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: $this->currentVersion !== '' ? $this->currentVersion : (string) config('constants.coolify.version'), + targetVersion: $this->latestVersion !== '' ? $this->latestVersion : get_latest_version_of_coolify(), + ); } } diff --git a/app/Services/CoolifyUpgradeStatus.php b/app/Services/CoolifyUpgradeStatus.php new file mode 100644 index 000000000..15f6c22ef --- /dev/null +++ b/app/Services/CoolifyUpgradeStatus.php @@ -0,0 +1,88 @@ + $runningVersion, + 'target_version' => $targetVersion, + ]; + + $content = trim($content); + if ($content === '') { + return ['status' => 'none', ...$base]; + } + + $parts = explode('|', $content); + if (count($parts) < 3) { + return ['status' => 'none', ...$base]; + } + + [$step, $message, $timestamp] = $parts; + + try { + $statusTime = new \DateTime($timestamp); + $now = $now ?? new \DateTime; + $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60; + + if ($diffMinutes > $staleAfterMinutes) { + return ['status' => 'none', ...$base]; + } + } catch (\Throwable) { + return ['status' => 'none', ...$base]; + } + + if ($step === 'error') { + return [ + 'status' => 'error', + 'step' => 0, + 'message' => $message, + ...$base, + ]; + } + + $stepInt = (int) $step; + + if ($stepInt >= 6 && ! self::hasReachedTargetVersion($runningVersion, $targetVersion)) { + return [ + 'status' => 'in_progress', + 'step' => $stepInt, + 'message' => "Waiting for Coolify {$targetVersion} to come online...", + ...$base, + ]; + } + + $status = $stepInt >= 6 ? 'complete' : 'in_progress'; + + return [ + 'status' => $status, + 'step' => $stepInt, + 'message' => $message, + ...$base, + ]; + } + + public static function hasReachedTargetVersion(string $runningVersion, string $targetVersion): bool + { + if ($runningVersion === '' || $targetVersion === '') { + return false; + } + + return version_compare($runningVersion, $targetVersion, '>='); + } +} diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php index cdcdc6134..81a1bce5d 100644 --- a/resources/views/livewire/upgrade.blade.php +++ b/resources/views/livewire/upgrade.blade.php @@ -171,6 +171,7 @@ checkUpgradeStatusInterval: null, elapsedInterval: null, healthCheckAttempts: 0, + livewireFailures: 0, startTime: null, elapsedTime: 0, currentStep: 0, @@ -254,6 +255,23 @@ return 4; }, + hasReachedTargetVersion(running, target) { + if (!running || !target) { + return false; + } + const normalize = (version) => String(version).replace(/^v/i, ''); + running = normalize(running); + target = normalize(target); + if (running === target) { + return true; + } + + return running.localeCompare(target, undefined, { + numeric: true, + sensitivity: 'base', + }) >= 0; + }, + getReviveStatusMessage(elapsedMinutes, attempts) { if (elapsedMinutes === 0) { return `Waiting for Coolify to come back online... (attempt ${attempts})`; @@ -278,8 +296,13 @@ const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000); fetch('/api/health') .then(response => { - if (response.ok) { + const runningVersion = response.headers.get('X-Coolify-Version'); + if (response.ok && this.hasReachedTargetVersion(runningVersion, this.latestVersion)) { this.showSuccess(); + } else if (response.ok) { + this.currentStatus = runningVersion + ? `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...` + : this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts); } else { this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts); } @@ -365,21 +388,33 @@ this.currentStep = 1; this.currentStatus = 'Starting upgrade...'; this.serviceDown = false; + this.livewireFailures = 0; // Poll upgrade status via Livewire this.checkUpgradeStatusInterval = setInterval(async () => { try { const data = await this.$wire.getUpgradeStatus(); + this.livewireFailures = 0; if (data.status === 'in_progress') { this.currentStep = this.mapStepToUI(data.step); this.currentStatus = data.message; } else if (data.status === 'complete') { - this.showSuccess(); + if (this.hasReachedTargetVersion(data.running_version || this.currentVersion, this.latestVersion)) { + this.showSuccess(); + } else { + this.currentStep = 4; + this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`; + } } else if (data.status === 'error') { this.showError(data.message); } } catch (error) { - // Service is down - switch to health check mode + this.livewireFailures++; + if (this.livewireFailures < 3) { + this.currentStatus = 'Lost contact with Coolify, retrying...'; + return; + } + // Repeated Livewire failures usually mean the instance is restarting console.log('Livewire unavailable, switching to health check mode'); if (!this.serviceDown) { this.serviceDown = true; diff --git a/tests/Feature/HealthcheckVersionHeaderTest.php b/tests/Feature/HealthcheckVersionHeaderTest.php new file mode 100644 index 000000000..7acf78c6f --- /dev/null +++ b/tests/Feature/HealthcheckVersionHeaderTest.php @@ -0,0 +1,22 @@ + '4.3.1']); + + $response = (new OtherController)->healthcheck(Request::create('/api/health', 'GET')); + + expect($response->getContent())->toBe('OK') + ->and($response->headers->get('X-Coolify-Version'))->toBe('4.3.1'); +}); + +it('exposes the running Coolify version on the public health endpoint', function () { + config(['constants.coolify.version' => '4.3.1']); + + $this->get('/api/health') + ->assertSuccessful() + ->assertSee('OK') + ->assertHeader('X-Coolify-Version', '4.3.1'); +}); diff --git a/tests/Unit/CoolifyUpgradeStatusTest.php b/tests/Unit/CoolifyUpgradeStatusTest.php new file mode 100644 index 000000000..7d45b0445 --- /dev/null +++ b/tests/Unit/CoolifyUpgradeStatusTest.php @@ -0,0 +1,147 @@ +toMatchArray([ + 'status' => 'none', + 'running_version' => '4.3.0', + 'target_version' => '4.3.1', + ]); +})->with([ + 'empty' => '', + 'whitespace' => ' ', + 'missing fields' => '6|Upgrade complete', + 'single token' => 'complete', +]); + +it('returns in_progress for intermediate upgrade steps', function () { + $content = '4|Stopping containers|'.Carbon::parse('2026-08-13T12:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.0', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:01:00+00:00'), + ); + + expect($result)->toMatchArray([ + 'status' => 'in_progress', + 'step' => 4, + 'message' => 'Stopping containers', + 'running_version' => '4.3.0', + 'target_version' => '4.3.1', + ]); +}); + +it('does not mark the upgrade complete when the script finished but the running version is still old', function () { + $content = '6|Upgrade complete|'.Carbon::parse('2026-08-13T12:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.0', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:01:00+00:00'), + ); + + expect($result['status'])->toBe('in_progress') + ->and($result['step'])->toBe(6) + ->and($result['running_version'])->toBe('4.3.0') + ->and($result['target_version'])->toBe('4.3.1') + ->and($result['message'])->toContain('Waiting for Coolify') + ->and($result['message'])->toContain('4.3.1'); +}); + +it('marks the upgrade complete only after the running version matches the target', function () { + $content = '6|Upgrade complete|'.Carbon::parse('2026-08-13T12:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.1', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:01:00+00:00'), + ); + + expect($result)->toMatchArray([ + 'status' => 'complete', + 'step' => 6, + 'message' => 'Upgrade complete', + 'running_version' => '4.3.1', + 'target_version' => '4.3.1', + ]); +}); + +it('treats a running version newer than the target as complete', function () { + $content = '6|Upgrade complete|'.Carbon::parse('2026-08-13T12:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.2', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:01:00+00:00'), + ); + + expect($result['status'])->toBe('complete'); +}); + +it('returns error status without requiring a version match', function () { + $content = 'error|Failed to pull image|'.Carbon::parse('2026-08-13T12:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.0', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:01:00+00:00'), + ); + + expect($result)->toMatchArray([ + 'status' => 'error', + 'step' => 0, + 'message' => 'Failed to pull image', + ]); +}); + +it('ignores stale status files older than ten minutes', function () { + $content = '6|Upgrade complete|'.Carbon::parse('2026-08-13T11:00:00+00:00')->toIso8601String(); + + $result = CoolifyUpgradeStatus::fromFile( + content: $content, + runningVersion: '4.3.1', + targetVersion: '4.3.1', + now: Carbon::parse('2026-08-13T12:00:00+00:00'), + ); + + expect($result['status'])->toBe('none'); +}); + +it('waits for the running version to match the target before showing reload', function () { + $upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php'); + + expect($upgradeView) + ->toContain('X-Coolify-Version') + ->toContain('hasReachedTargetVersion') + ->toContain('livewireFailures'); +}); + +it('starts the upgrade after the Livewire response so status polling is not blocked', function () { + $upgradeComponent = file_get_contents(__DIR__.'/../../app/Livewire/Upgrade.php'); + + expect($upgradeComponent) + ->toContain('afterResponse()') + ->toContain('CoolifyUpgradeStatus::fromFile'); +}); + +it('reports whether the running version has reached the target', function (string $running, string $target, bool $reached) { + expect(CoolifyUpgradeStatus::hasReachedTargetVersion($running, $target))->toBe($reached); +})->with([ + 'old instance still serving' => ['4.3.0', '4.3.1', false], + 'target reached' => ['4.3.1', '4.3.1', true], + 'already newer' => ['4.3.2', '4.3.1', true], + 'missing running version' => ['', '4.3.1', false], + 'missing target version' => ['4.3.1', '', false], +]);