fix(upgrade): use authenticated status polling after restart

Remove the version header from the health endpoint and use upgrade status polling to determine completion.
This commit is contained in:
Andras Bacsai 2026-08-13 14:23:29 +02:00
parent 1932673c22
commit 6492d08136
4 changed files with 38 additions and 80 deletions

View file

@ -292,20 +292,13 @@ public function feedback(Request $request)
#[OA\Get( #[OA\Get(
summary: 'Healthcheck', summary: 'Healthcheck',
description: 'Healthcheck endpoint. Includes the running Coolify version in the X-Coolify-Version header.', description: 'Healthcheck endpoint.',
path: '/health', path: '/health',
operationId: 'healthcheck', operationId: 'healthcheck',
responses: [ responses: [
new OA\Response( new OA\Response(
response: 200, response: 200,
description: 'Healthcheck endpoint.', 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( content: new OA\MediaType(
mediaType: 'text/html', mediaType: 'text/html',
schema: new OA\Schema(type: 'string'), schema: new OA\Schema(type: 'string'),
@ -323,6 +316,6 @@ public function feedback(Request $request)
)] )]
public function healthcheck(Request $request) public function healthcheck(Request $request)
{ {
return response('OK')->header('X-Coolify-Version', (string) config('constants.coolify.version')); return response('OK');
} }
} }

View file

@ -256,34 +256,6 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
return 4; 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;
},
isReadyToReload(runningVersion) {
if (this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
return true;
}
// Releases before this header existed (e.g. 4.3.1) still
// return a healthy /api/health with no X-Coolify-Version.
// Only treat that as done after the instance actually went down.
return !runningVersion && this.instanceWentDown;
},
startHealthWatch() { startHealthWatch() {
if (this.checkHealthInterval) { if (this.checkHealthInterval) {
return; return;
@ -293,38 +265,36 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
}, 2000); }, 2000);
}, },
probeHealth() { async probeHealth() {
this.healthCheckAttempts++; this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000); const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
return fetch('/api/health') try {
.then(response => { const response = await fetch('/api/health');
const runningVersion = response.headers.get('X-Coolify-Version'); if (!response.ok) {
if (!response.ok) {
this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
return;
}
if (this.isReadyToReload(runningVersion)) {
this.showSuccess();
return;
}
if (!this.instanceWentDown && this.currentStep < 4) {
return;
}
if (runningVersion) {
this.currentStatus = `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`;
} else {
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
})
.catch(error => {
console.error('Health check failed:', error);
this.instanceWentDown = true; this.instanceWentDown = true;
this.currentStep = 4; this.currentStep = 4;
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts); this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}); return;
}
if (!this.instanceWentDown && this.currentStep < 4) {
return;
}
const data = await this.$wire.getUpgradeStatus();
if (data.status === 'complete') {
this.showSuccess();
} else if (data.status === 'error') {
this.showError(data.message);
} else {
this.currentStatus = data.message ?? this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
} catch (error) {
console.error('Health check failed:', error);
this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
}, },
getReviveStatusMessage(elapsedMinutes, attempts) { getReviveStatusMessage(elapsedMinutes, attempts) {
@ -434,13 +404,7 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
this.currentStep = this.mapStepToUI(data.step); this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message; this.currentStatus = data.message;
} else if (data.status === 'complete') { } else if (data.status === 'complete') {
if (this.isReadyToReload(data.running_version)) { this.showSuccess();
this.showSuccess();
} else {
this.currentStep = 4;
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
this.revive();
}
} else if (data.status === 'error') { } else if (data.status === 'error') {
this.showError(data.message); this.showError(data.message);
} else if (data.status === 'none' && this.instanceWentDown) { } else if (data.status === 'none' && this.instanceWentDown) {

View file

@ -3,20 +3,20 @@
use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\OtherController;
use Illuminate\Http\Request; use Illuminate\Http\Request;
it('adds the running version header on the healthcheck response', function () { it('does not expose the running version on the healthcheck response', function () {
config(['constants.coolify.version' => '4.3.1']); config(['constants.coolify.version' => '4.3.1']);
$response = (new OtherController)->healthcheck(Request::create('/api/health', 'GET')); $response = (new OtherController)->healthcheck(Request::create('/api/health', 'GET'));
expect($response->getContent())->toBe('OK') expect($response->getContent())->toBe('OK')
->and($response->headers->get('X-Coolify-Version'))->toBe('4.3.1'); ->and($response->headers->has('X-Coolify-Version'))->toBeFalse();
}); });
it('exposes the running Coolify version on the public health endpoint', function () { it('does not expose the running Coolify version on the public health endpoint', function () {
config(['constants.coolify.version' => '4.3.1']); config(['constants.coolify.version' => '4.3.1']);
$this->get('/api/health') $this->get('/api/health')
->assertSuccessful() ->assertSuccessful()
->assertSee('OK') ->assertSee('OK')
->assertHeader('X-Coolify-Version', '4.3.1'); ->assertHeaderMissing('X-Coolify-Version');
}); });

View file

@ -119,23 +119,24 @@
expect($result['status'])->toBe('none'); expect($result['status'])->toBe('none');
}); });
it('waits for the running version to match the target before showing reload', function () { it('checks the running version through authenticated upgrade status before showing reload', function () {
$upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php'); $upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php');
expect($upgradeView) expect($upgradeView)
->toContain('X-Coolify-Version') ->not->toContain('X-Coolify-Version')
->toContain('hasReachedTargetVersion') ->toContain('this.$wire.getUpgradeStatus()')
->toContain("data.status === 'complete'")
->toContain('livewireFailures'); ->toContain('livewireFailures');
}); });
it('treats a healthy instance without a version header as ready only after downtime', function () { it('uses the public health endpoint only for liveness during an upgrade', function () {
$upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php'); $upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php');
expect($upgradeView) expect($upgradeView)
->toContain('instanceWentDown') ->toContain('instanceWentDown')
->toContain('isReadyToReload')
->toContain('startHealthWatch') ->toContain('startHealthWatch')
->toContain('data.status === \'none\''); ->toContain("fetch('/api/health')")
->not->toContain('response.headers.get');
}); });
it('starts the upgrade after the Livewire response so status polling is not blocked', function () { it('starts the upgrade after the Livewire response so status polling is not blocked', function () {