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
+2 -9
View File
@@ -292,20 +292,13 @@ class OtherController extends Controller
#[OA\Get(
summary: 'Healthcheck',
description: 'Healthcheck endpoint. Includes the running Coolify version in the X-Coolify-Version header.',
description: 'Healthcheck endpoint.',
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'),
@@ -323,6 +316,6 @@ class OtherController extends Controller
)]
public function healthcheck(Request $request)
{
return response('OK')->header('X-Coolify-Version', (string) config('constants.coolify.version'));
return response('OK');
}
}
+25 -61
View File
@@ -256,34 +256,6 @@
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() {
if (this.checkHealthInterval) {
return;
@@ -293,38 +265,36 @@
}, 2000);
},
probeHealth() {
async probeHealth() {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
return fetch('/api/health')
.then(response => {
const runningVersion = response.headers.get('X-Coolify-Version');
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);
try {
const response = await fetch('/api/health');
if (!response.ok) {
this.instanceWentDown = true;
this.currentStep = 4;
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) {
@@ -434,13 +404,7 @@
this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message;
} else if (data.status === 'complete') {
if (this.isReadyToReload(data.running_version)) {
this.showSuccess();
} else {
this.currentStep = 4;
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
this.revive();
}
this.showSuccess();
} else if (data.status === 'error') {
this.showError(data.message);
} else if (data.status === 'none' && this.instanceWentDown) {
@@ -3,20 +3,20 @@
use App\Http\Controllers\Api\OtherController;
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']);
$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');
->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']);
$this->get('/api/health')
->assertSuccessful()
->assertSee('OK')
->assertHeader('X-Coolify-Version', '4.3.1');
->assertHeaderMissing('X-Coolify-Version');
});
+7 -6
View File
@@ -119,23 +119,24 @@ it('ignores stale status files older than ten minutes', function () {
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');
expect($upgradeView)
->toContain('X-Coolify-Version')
->toContain('hasReachedTargetVersion')
->not->toContain('X-Coolify-Version')
->toContain('this.$wire.getUpgradeStatus()')
->toContain("data.status === 'complete'")
->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');
expect($upgradeView)
->toContain('instanceWentDown')
->toContain('isReadyToReload')
->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 () {