diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 0bf763d78..a26e7daaa 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -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); } diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 4f2bfa68c..8da2426da 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -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]); } diff --git a/app/Jobs/CheckHelperImageJob.php b/app/Jobs/CheckHelperImageJob.php index 6abb8a150..6d76da8eb 100644 --- a/app/Jobs/CheckHelperImageJob.php +++ b/app/Jobs/CheckHelperImageJob.php @@ -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(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 1b23247fa..1066f1a63 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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'); diff --git a/config/constants.php b/config/constants.php index 893fb11fd..9b1dd5f68 100644 --- a/config/constants.php +++ b/config/constants.php @@ -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', ], diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php new file mode 100644 index 000000000..649ecdeb8 --- /dev/null +++ b/tests/Unit/CheckForUpdatesJobTest.php @@ -0,0 +1,183 @@ +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(); +}); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php new file mode 100644 index 000000000..3a89d7ea9 --- /dev/null +++ b/tests/Unit/UpdateCoolifyTest.php @@ -0,0 +1,133 @@ +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'); + } +});