mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 10:15:05 +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:
1 parent
882e25e072
commit
6481ffffcf
6 files changed
+317
-46
No files matched your search
@@ -292,13 +292,20 @@ class OtherController extends Controller
|
|||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
summary: 'Healthcheck',
|
summary: 'Healthcheck',
|
||||||
description: 'Healthcheck endpoint.',
|
description: 'Healthcheck endpoint. Includes the running Coolify version in the X-Coolify-Version header.',
|
||||||
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'),
|
||||||
@@ -316,6 +323,6 @@ class OtherController extends Controller
|
|||||||
)]
|
)]
|
||||||
public function healthcheck(Request $request)
|
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\Actions\Server\UpdateCoolify;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\CoolifyUpgradeStatus;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Upgrade extends Component
|
class Upgrade extends Component
|
||||||
@@ -69,7 +70,13 @@ class Upgrade extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->updateInProgress = true;
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
@@ -100,45 +107,10 @@ class Upgrade extends Component
|
|||||||
return ['status' => 'none'];
|
return ['status' => 'none'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($content)) {
|
return CoolifyUpgradeStatus::fromFile(
|
||||||
return ['status' => 'none'];
|
content: $content,
|
||||||
}
|
runningVersion: $this->currentVersion !== '' ? $this->currentVersion : (string) config('constants.coolify.version'),
|
||||||
|
targetVersion: $this->latestVersion !== '' ? $this->latestVersion : get_latest_version_of_coolify(),
|
||||||
$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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
checkUpgradeStatusInterval: null,
|
||||||
elapsedInterval: null,
|
elapsedInterval: null,
|
||||||
healthCheckAttempts: 0,
|
healthCheckAttempts: 0,
|
||||||
|
livewireFailures: 0,
|
||||||
startTime: null,
|
startTime: null,
|
||||||
elapsedTime: 0,
|
elapsedTime: 0,
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
@@ -254,6 +255,23 @@
|
|||||||
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;
|
||||||
|
},
|
||||||
|
|
||||||
getReviveStatusMessage(elapsedMinutes, attempts) {
|
getReviveStatusMessage(elapsedMinutes, attempts) {
|
||||||
if (elapsedMinutes === 0) {
|
if (elapsedMinutes === 0) {
|
||||||
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
|
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
|
||||||
@@ -278,8 +296,13 @@
|
|||||||
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
|
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
|
||||||
fetch('/api/health')
|
fetch('/api/health')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.ok) {
|
const runningVersion = response.headers.get('X-Coolify-Version');
|
||||||
|
if (response.ok && this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
|
||||||
this.showSuccess();
|
this.showSuccess();
|
||||||
|
} else if (response.ok) {
|
||||||
|
this.currentStatus = runningVersion
|
||||||
|
? `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`
|
||||||
|
: this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||||
} else {
|
} else {
|
||||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||||
}
|
}
|
||||||
@@ -365,21 +388,33 @@
|
|||||||
this.currentStep = 1;
|
this.currentStep = 1;
|
||||||
this.currentStatus = 'Starting upgrade...';
|
this.currentStatus = 'Starting upgrade...';
|
||||||
this.serviceDown = false;
|
this.serviceDown = false;
|
||||||
|
this.livewireFailures = 0;
|
||||||
|
|
||||||
// Poll upgrade status via Livewire
|
// Poll upgrade status via Livewire
|
||||||
this.checkUpgradeStatusInterval = setInterval(async () => {
|
this.checkUpgradeStatusInterval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await this.$wire.getUpgradeStatus();
|
const data = await this.$wire.getUpgradeStatus();
|
||||||
|
this.livewireFailures = 0;
|
||||||
if (data.status === 'in_progress') {
|
if (data.status === 'in_progress') {
|
||||||
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') {
|
||||||
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') {
|
} else if (data.status === 'error') {
|
||||||
this.showError(data.message);
|
this.showError(data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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');
|
console.log('Livewire unavailable, switching to health check mode');
|
||||||
if (!this.serviceDown) {
|
if (!this.serviceDown) {
|
||||||
this.serviceDown = true;
|
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