diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 7f3e5fb8bf..0dc3b5f35b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -51,6 +51,11 @@ class Kernel extends ConsoleKernel ->name('cleanup:ssh-mux') ->hourly() ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); + // Every cloud node reads its own versions.json, so each node refreshes it (no onOneServer). + $this->scheduleInstance->call(fn () => refresh_versions_file()) + ->name('versions:refresh-local') + ->hourly() + ->when(fn () => isCloud()); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->call(fn () => app(ScheduledJobDeliveryService::class)->deleteOldOccurrences()) ->name('cleanup:scheduled-job-occurrences') diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index b4cb7fe705..01a0e69166 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -8,9 +8,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; 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,67 +19,16 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue if (isDev() || isCloud()) { return; } - $settings = instanceSettings(); - $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(); - - CheckTraefikVersionJob::dispatch(); - - // 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]); - } else { - $settings->update(['new_version_available' => false]); - } + $versions = refresh_versions_file(); + if ($versions === null) { + return; } + + CheckTraefikVersionJob::dispatch(); + + $latestVersion = data_get($versions, 'coolify.v4.version'); + $newVersionAvailable = version_compare($latestVersion, config('constants.coolify.version'), '>'); + instanceSettings()->update(['new_version_available' => $newVersionAvailable]); } catch (\Throwable $e) { // Consider implementing a notification to administrators } diff --git a/bootstrap/helpers/versions.php b/bootstrap/helpers/versions.php index bb4694de58..d6a714cc9e 100644 --- a/bootstrap/helpers/versions.php +++ b/bootstrap/helpers/versions.php @@ -2,6 +2,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; /** * Get cached versions data from versions.json. @@ -51,3 +53,59 @@ function invalidate_versions_cache(): void { Cache::forget('coolify:versions:all'); } + +/** + * Fetch versions.json from the CDN and write it to the local file of this node. + * The Coolify version never goes below the cached or the running version. + * When the CDN fails, the local file stays as it is. + * + * @return array|null The written versions data, or null if the CDN fetch failed + */ +function refresh_versions_file(): ?array +{ + $response = Http::timeout(10)->retry(3, 1000, throw: false)->get(config('constants.coolify.versions_url')); + $versions = $response->json(); + if ($response->failed() || ! is_array($versions)) { + Log::warning('Could not fetch versions.json from the CDN, using the local file', ['status' => $response->status()]); + + return null; + } + + $latestVersion = data_get($versions, 'coolify.v4.version'); + $currentVersion = config('constants.coolify.version'); + + $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'); + } + + $bestVersion = $latestVersion; + + if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) { + Log::warning('CDN served older Coolify version than cache', [ + 'cdn_version' => $latestVersion, + 'cached_version' => $existingCoolifyVersion, + 'current_version' => $currentVersion, + ]); + $bestVersion = $existingCoolifyVersion; + } + + if (version_compare($bestVersion, $currentVersion, '<')) { + Log::warning('Version downgrade prevented in CheckForUpdatesJob', [ + 'cdn_version' => $latestVersion, + 'cached_version' => $existingCoolifyVersion, + 'current_version' => $currentVersion, + 'attempted_best' => $bestVersion, + 'using' => $currentVersion, + ]); + $bestVersion = $currentVersion; + } + + data_set($versions, 'coolify.v4.version', $bestVersion); + + File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); + invalidate_versions_cache(); + + return $versions; +} diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 19edd2af41..c195d831ba 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -41,6 +41,20 @@ it('schedules ssh mux cleanup locally on every scheduler host', function () { expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux'); }); +it('refreshes the local versions.json hourly on every cloud node', function (bool $cloud) { + config(['constants.coolify.self_hosted' => ! $cloud]); + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($e) => (string) $e->description === 'versions:refresh-local' + ); + + expect($event)->not->toBeNull(); + expect($event->onOneServer)->toBeFalse(); + expect($event->expression)->toBe('0 * * * *'); + expect($event->filtersPass(app()))->toBe($cloud); +})->with(['cloud' => true, 'self-hosted' => false]); + it('schedules every production job with onOneServer', function () { $schedule = app(Schedule::class); diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php index 0b04224c5e..6b9706e4c4 100644 --- a/tests/Unit/CheckForUpdatesJobTest.php +++ b/tests/Unit/CheckForUpdatesJobTest.php @@ -163,3 +163,11 @@ it('preserves other component versions when preventing Coolify downgrade', funct $job = new CheckForUpdatesJob; $job->handle(); }); + +it('keeps the local versions.json when the CDN fetch fails', function () { + Http::fake(['*' => Http::response('', 500)]); + File::shouldReceive('put')->never(); + Cache::shouldReceive('forget')->never(); + + expect(refresh_versions_file())->toBeNull(); +});