fix(upgrade): wait for new version before showing success

Expose the running Coolify version on /api/health and only treat an upgrade as complete once that version meets the target. Parse upgrade status in a shared service, start the update after the Livewire response, and keep polling when the instance is still on the old version.
This commit is contained in:
Andras Bacsai
2026-08-13 10:46:06 +02:00
parent 882e25e072
commit 6481ffffcf
6 changed files with 317 additions and 46 deletions
+9 -2
View File
@@ -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'));
}
}
+13 -41
View File
@@ -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(),
);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use DateTimeInterface;
class CoolifyUpgradeStatus
{
public const STALE_AFTER_MINUTES = 10;
/**
* @return array{status: string, step?: int, message?: string, running_version: string, target_version: string}
*/
public static function fromFile(
string $content,
string $runningVersion,
string $targetVersion,
?DateTimeInterface $now = null,
int $staleAfterMinutes = self::STALE_AFTER_MINUTES,
): array {
$base = [
'running_version' => $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, '>=');
}
}
+38 -3
View File
@@ -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;
@@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\Api\OtherController;
use Illuminate\Http\Request;
it('adds the running version header 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');
});
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');
});
+147
View File
@@ -0,0 +1,147 @@
<?php
use App\Services\CoolifyUpgradeStatus;
use Carbon\Carbon;
it('treats empty or malformed status as none', function (?string $content) {
expect(CoolifyUpgradeStatus::fromFile(
content: $content ?? '',
runningVersion: '4.3.0',
targetVersion: '4.3.1',
))->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],
]);