Fix: Version downgrade prevention with cache validation (#7396)
This commit is contained in:
commit
b246cdffab
7 changed files with 438 additions and 9 deletions
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -29,7 +31,59 @@ public function handle($manual_update = false)
|
|||
return;
|
||||
}
|
||||
CleanupDocker::dispatch($this->server, false, false);
|
||||
$this->latestVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Fetch fresh version from CDN instead of using cache
|
||||
try {
|
||||
$response = Http::retry(3, 1000)->timeout(10)
|
||||
->get(config('constants.coolify.versions_url'));
|
||||
|
||||
if ($response->successful()) {
|
||||
$versions = $response->json();
|
||||
$this->latestVersion = data_get($versions, 'coolify.v4.version');
|
||||
} else {
|
||||
// Fallback to cache if CDN unavailable
|
||||
$cacheVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Validate cache version against current running version
|
||||
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||
'cached_version' => $cacheVersion,
|
||||
'current_version' => config('constants.coolify.version'),
|
||||
]);
|
||||
throw new \Exception(
|
||||
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||
);
|
||||
}
|
||||
|
||||
$this->latestVersion = $cacheVersion;
|
||||
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
|
||||
'version' => $cacheVersion,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$cacheVersion = get_latest_version_of_coolify();
|
||||
|
||||
// Validate cache version against current running version
|
||||
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||
'error' => $e->getMessage(),
|
||||
'cached_version' => $cacheVersion,
|
||||
'current_version' => config('constants.coolify.version'),
|
||||
]);
|
||||
throw new \Exception(
|
||||
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||
);
|
||||
}
|
||||
|
||||
$this->latestVersion = $cacheVersion;
|
||||
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
|
||||
'error' => $e->getMessage(),
|
||||
'version' => $cacheVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->currentVersion = config('constants.coolify.version');
|
||||
if (! $manual_update) {
|
||||
if (! $settings->is_auto_update_enabled) {
|
||||
|
|
@ -42,6 +96,20 @@ public function handle($manual_update = false)
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ALWAYS check for downgrades (even for manual updates)
|
||||
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
|
||||
Log::error('Downgrade prevented', [
|
||||
'target_version' => $this->latestVersion,
|
||||
'current_version' => $this->currentVersion,
|
||||
'manual_update' => $manual_update,
|
||||
]);
|
||||
throw new \Exception(
|
||||
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
|
||||
'If you need to downgrade, please do so manually via Docker commands.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->update();
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
|
|
@ -56,8 +124,9 @@ private function update()
|
|||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
remote_process([
|
||||
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
|
||||
], $this->server);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -22,20 +23,60 @@ public function handle(): void
|
|||
return;
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
|
||||
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
|
||||
if ($response->successful()) {
|
||||
$versions = $response->json();
|
||||
|
||||
$latest_version = data_get($versions, 'coolify.v4.version');
|
||||
$current_version = config('constants.coolify.version');
|
||||
|
||||
// Read existing cached version
|
||||
$existingVersions = null;
|
||||
$existingCoolifyVersion = null;
|
||||
if (File::exists(base_path('versions.json'))) {
|
||||
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
|
||||
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
|
||||
}
|
||||
|
||||
// Determine the BEST version to use (CDN, cache, or current)
|
||||
$bestVersion = $latest_version;
|
||||
|
||||
// Check if cache has newer version than CDN
|
||||
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
|
||||
Log::warning('CDN served older Coolify version than cache', [
|
||||
'cdn_version' => $latest_version,
|
||||
'cached_version' => $existingCoolifyVersion,
|
||||
'current_version' => $current_version,
|
||||
]);
|
||||
$bestVersion = $existingCoolifyVersion;
|
||||
}
|
||||
|
||||
// CRITICAL: Never allow bestVersion to be older than currently running version
|
||||
if (version_compare($bestVersion, $current_version, '<')) {
|
||||
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
|
||||
'cdn_version' => $latest_version,
|
||||
'cached_version' => $existingCoolifyVersion,
|
||||
'current_version' => $current_version,
|
||||
'attempted_best' => $bestVersion,
|
||||
'using' => $current_version,
|
||||
]);
|
||||
$bestVersion = $current_version;
|
||||
}
|
||||
|
||||
// Use data_set() for safe mutation (fixes #3)
|
||||
data_set($versions, 'coolify.v4.version', $bestVersion);
|
||||
$latest_version = $bestVersion;
|
||||
|
||||
// ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
|
||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||
|
||||
// Invalidate cache to ensure fresh data is loaded
|
||||
invalidate_versions_cache();
|
||||
|
||||
// Only mark new version available if Coolify version actually increased
|
||||
if (version_compare($latest_version, $current_version, '>')) {
|
||||
// New version available
|
||||
$settings->update(['new_version_available' => true]);
|
||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||
|
||||
// Invalidate cache to ensure fresh data is loaded
|
||||
invalidate_versions_cache();
|
||||
} else {
|
||||
$settings->update(['new_version_available' => false]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public function __construct() {}
|
|||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
|
||||
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
|
||||
if ($response->successful()) {
|
||||
$versions = $response->json();
|
||||
$settings = instanceSettings();
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ function get_route_parameters(): array
|
|||
function get_latest_sentinel_version(): string
|
||||
{
|
||||
try {
|
||||
$response = Http::get('https://cdn.coollabs.io/coolify/versions.json');
|
||||
$response = Http::get(config('constants.coolify.versions_url'));
|
||||
$versions = $response->json();
|
||||
|
||||
return data_get($versions, 'coolify.sentinel.version');
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
|
||||
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
|
||||
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
|
||||
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
|
||||
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
|
||||
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
|
||||
'releases_url' => 'https://cdn.coolify.io/releases.json',
|
||||
],
|
||||
|
||||
|
|
|
|||
183
tests/Unit/CheckForUpdatesJobTest.php
Normal file
183
tests/Unit/CheckForUpdatesJobTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
// Mock InstanceSettings
|
||||
$this->settings = Mockery::mock(InstanceSettings::class);
|
||||
$this->settings->shouldReceive('update')->andReturn(true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('has correct job configuration', function () {
|
||||
$job = new CheckForUpdatesJob;
|
||||
|
||||
$interfaces = class_implements($job);
|
||||
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
|
||||
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldBeEncrypted::class);
|
||||
});
|
||||
|
||||
it('uses max of CDN and cache versions', function () {
|
||||
// CDN has older version
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.0']],
|
||||
'traefik' => ['v3.5' => '3.5.6'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
// Cache has newer version
|
||||
File::shouldReceive('exists')
|
||||
->with(base_path('versions.json'))
|
||||
->andReturn(true);
|
||||
|
||||
File::shouldReceive('get')
|
||||
->with(base_path('versions.json'))
|
||||
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
$data = json_decode($json, true);
|
||||
// Should use cached version (4.0.10), not CDN version (4.0.0)
|
||||
return $data['coolify']['v4']['version'] === '4.0.10';
|
||||
}));
|
||||
|
||||
Cache::shouldReceive('forget')->once();
|
||||
|
||||
config(['constants.coolify.version' => '4.0.5']);
|
||||
|
||||
// Mock instanceSettings function
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
$job = new CheckForUpdatesJob();
|
||||
$job->handle();
|
||||
});
|
||||
|
||||
it('never downgrades from current running version', function () {
|
||||
// CDN has older version
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.0']],
|
||||
'traefik' => ['v3.5' => '3.5.6'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
// Cache also has older version
|
||||
File::shouldReceive('exists')
|
||||
->with(base_path('versions.json'))
|
||||
->andReturn(true);
|
||||
|
||||
File::shouldReceive('get')
|
||||
->with(base_path('versions.json'))
|
||||
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
$data = json_decode($json, true);
|
||||
// Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5)
|
||||
return $data['coolify']['v4']['version'] === '4.0.10';
|
||||
}));
|
||||
|
||||
Cache::shouldReceive('forget')->once();
|
||||
|
||||
// Running version is newest
|
||||
config(['constants.coolify.version' => '4.0.10']);
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
|
||||
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
$job = new CheckForUpdatesJob();
|
||||
$job->handle();
|
||||
});
|
||||
|
||||
it('uses data_set for safe version mutation', function () {
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.10']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('exists')->andReturn(false);
|
||||
File::shouldReceive('put')->once();
|
||||
Cache::shouldReceive('forget')->once();
|
||||
|
||||
config(['constants.coolify.version' => '4.0.5']);
|
||||
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
$job = new CheckForUpdatesJob();
|
||||
|
||||
// Should not throw even if structure is unexpected
|
||||
// data_set() handles nested path creation
|
||||
$job->handle();
|
||||
})->skip('Needs better mock setup for instanceSettings');
|
||||
|
||||
it('preserves other component versions when preventing Coolify downgrade', function () {
|
||||
// CDN has older Coolify but newer Traefik
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.0']],
|
||||
'traefik' => ['v3.6' => '3.6.2'],
|
||||
'sentinel' => ['version' => '1.0.5'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('exists')->andReturn(true);
|
||||
File::shouldReceive('get')
|
||||
->andReturn(json_encode([
|
||||
'coolify' => ['v4' => ['version' => '4.0.5']],
|
||||
'traefik' => ['v3.5' => '3.5.6'],
|
||||
]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
$data = json_decode($json, true);
|
||||
// Coolify should use running version
|
||||
expect($data['coolify']['v4']['version'])->toBe('4.0.10');
|
||||
// Traefik should use CDN version (newer)
|
||||
expect($data['traefik']['v3.6'])->toBe('3.6.2');
|
||||
// Sentinel should use CDN version
|
||||
expect($data['sentinel']['version'])->toBe('1.0.5');
|
||||
return true;
|
||||
}));
|
||||
|
||||
Cache::shouldReceive('forget')->once();
|
||||
|
||||
config(['constants.coolify.version' => '4.0.10']);
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('CDN served older Coolify version than cache', Mockery::type('array'));
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
|
||||
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
$job = new CheckForUpdatesJob();
|
||||
$job->handle();
|
||||
});
|
||||
133
tests/Unit/UpdateCoolifyTest.php
Normal file
133
tests/Unit/UpdateCoolifyTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock Server
|
||||
$this->mockServer = Mockery::mock(Server::class)->makePartial();
|
||||
$this->mockServer->id = 0;
|
||||
|
||||
// Mock InstanceSettings
|
||||
$this->settings = Mockery::mock(InstanceSettings::class);
|
||||
$this->settings->is_auto_update_enabled = true;
|
||||
$this->settings->shouldReceive('save')->andReturn(true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('has UpdateCoolify action class', function () {
|
||||
expect(class_exists(UpdateCoolify::class))->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates cache against running version before fallback', function () {
|
||||
// Mock Server::find to return our mock server
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
// CDN fails
|
||||
Http::fake(['*' => Http::response(null, 500)]);
|
||||
|
||||
// Mock cache returning older version
|
||||
Cache::shouldReceive('remember')
|
||||
->andReturn(['coolify' => ['v4' => ['version' => '4.0.5']]]);
|
||||
|
||||
config(['constants.coolify.version' => '4.0.10']);
|
||||
|
||||
$action = new UpdateCoolify();
|
||||
|
||||
// Should throw exception - cache is older than running
|
||||
try {
|
||||
$action->handle(manual_update: false);
|
||||
expect(false)->toBeTrue('Expected exception was not thrown');
|
||||
} catch (\Exception $e) {
|
||||
expect($e->getMessage())->toContain('cache version');
|
||||
expect($e->getMessage())->toContain('4.0.5');
|
||||
expect($e->getMessage())->toContain('4.0.10');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses validated cache when CDN fails and cache is newer', function () {
|
||||
// Mock Server::find
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
// CDN fails
|
||||
Http::fake(['*' => Http::response(null, 500)]);
|
||||
|
||||
// Cache has newer version than current
|
||||
Cache::shouldReceive('remember')
|
||||
->andReturn(['coolify' => ['v4' => ['version' => '4.0.10']]]);
|
||||
|
||||
config(['constants.coolify.version' => '4.0.5']);
|
||||
|
||||
// Mock the update method to prevent actual update
|
||||
$action = Mockery::mock(UpdateCoolify::class)->makePartial();
|
||||
$action->shouldReceive('update')->once();
|
||||
$action->server = $this->mockServer;
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
|
||||
|
||||
// Should not throw - cache (4.0.10) > running (4.0.5)
|
||||
$action->handle(manual_update: false);
|
||||
|
||||
expect($action->latestVersion)->toBe('4.0.10');
|
||||
});
|
||||
|
||||
it('prevents downgrade even with manual update', function () {
|
||||
// Mock Server::find
|
||||
Server::shouldReceive('find')
|
||||
->with(0)
|
||||
->andReturn($this->mockServer);
|
||||
|
||||
// Mock instanceSettings
|
||||
$this->app->instance('App\Models\InstanceSettings', function () {
|
||||
return $this->settings;
|
||||
});
|
||||
|
||||
// CDN returns older version
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.0']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
// Current version is newer
|
||||
config(['constants.coolify.version' => '4.0.10']);
|
||||
|
||||
$action = new UpdateCoolify();
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('error')
|
||||
->once()
|
||||
->with('Downgrade prevented', Mockery::type('array'));
|
||||
|
||||
// Should throw exception even for manual updates
|
||||
try {
|
||||
$action->handle(manual_update: true);
|
||||
expect(false)->toBeTrue('Expected exception was not thrown');
|
||||
} catch (\Exception $e) {
|
||||
expect($e->getMessage())->toContain('Cannot downgrade');
|
||||
expect($e->getMessage())->toContain('4.0.10');
|
||||
expect($e->getMessage())->toContain('4.0.0');
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue