mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-18 14:23:15 +00:00
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:
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '>=');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
Reference in New Issue
Block a user