diff --git a/CHANGELOG.md b/CHANGELOG.md index a5280b75af..1697c45ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Install the newest preview or nightly version with `install.sh --preview` or `install.sh --nightly`, which also switches the update channel of the instance +- Switch the update channel from the command line with `php artisan app:update-channel` - Attach an SBOM attestation to every image, which lists every package of the image - Copy the labels of the image to the annotations of its index, ghcr.io reads the description and the license of an image of several platforms from there - Choose the update channel of an instance on the update page: stable, preview with betas and release candidates, nightly with a warning that breaking changes are possible, or a custom image such as `pr-1234` @@ -38,7 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Move the helper image to [coollabsio/coolify-helper](https://github.com/coollabsio/coolify-helper), which releases it - Replace the release process, `RELEASE.md` describes it - Keep the Coolify versions in `release.json`, `stable.coolify` for `main` and the maintenance branches and `preview.coolify` for `next` and the release branches - - Pin the helper, Sentinel and Traefik versions in `upstream.json` instead of `versions.json`, the CDN and the image write `versions.json` from it + - Pin the helper, Sentinel and Traefik versions in `upstream.json` instead of `versions.json`, the CDN writes `versions.json` from it - Release from `main`, `next`, release branches such as `release/v4.6` and maintenance branches such as `v4.x` and `v4.4.x` - Select the stage and the version with `scripts/release.sh`, which offers the versions the branch allows and preselects the bump the commits need - Take the release commit back before the push with `scripts/undo-release.sh` @@ -60,7 +62,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Add a preview channel for the betas and release candidates - Sync every channel with the install and upgrade scripts and the compose files of the commit it releases, instead of the copies in `other/nightly` - Clear the Cloudflare cache of the CDN with `scripts/sync-cdn.sh --purge` - - List the Coolify version of every channel in `/coolify/release.json` + - List the Coolify, helper, Sentinel and Traefik versions of every channel in `/coolify/versions.json`, older instances keep reading its `coolify` and `traefik` keys - Take the Coolify version from the image alone, the upgrade script removes `COOLIFY_VERSION` from `.env` ### Removed diff --git a/README.md b/README.md index de6b80ab95..b2a7c0b256 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Open source & free forever, backed by our [philosophy](https://coolify.io/philosophy). -![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge +![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=stable.coolify.version&style=for-the-badge ) [Website](https://coolify.io) · [Documentation](https://coolify.io/docs) · [Cloud](https://app.coolify.io) · [Discord](https://coollabs.io/discord) · [Community](https://github.com/coollabsio/coolify/discussions) diff --git a/RELEASE.md b/RELEASE.md index 81a11a7588..06b13ad1d3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -160,7 +160,7 @@ The pull request workflow builds every pull request and publishes the image of a ## CDN -`scripts/sync-cdn.sh` syncs a channel of `coollabsio/coollabs-cdn` from the commit of the version it syncs: the install and upgrade scripts, the compose files, the environment file, the service templates, the GitHub releases and `versions.json`. +`scripts/sync-cdn.sh` syncs a channel of `coollabsio/coollabs-cdn` from the commit of the version it syncs: the install and upgrade scripts, the compose files, the environment file, the service templates, the GitHub releases and the block of the channel in `/coolify/versions.json`. | Channel | Path | Synced by | | --------- | ------------------- | --------------------------------------------------------- | @@ -170,9 +170,8 @@ The pull request workflow builds every pull request and publishes the image of a - The preview channel takes betas and release candidates, alphas reach the nightly channel only - `CDN_TOKEN` in the environment `release` is a fine-grained token for `coollabsio/coollabs-cdn` alone, with read and write access to contents and pull requests -- `versions.json` of a channel names its Coolify version and the helper, Sentinel and Traefik versions of `upstream.json` -- `/coolify/release.json` lists the Coolify version of every channel, each sync sets the version of its channel -- An instance on the preview or nightly channel takes the newest version of its channel and the more stable ones from `/coolify/release.json` +- `/coolify/versions.json` has a `stable`, `preview` and `nightly` block, each with the Coolify version of its channel and the helper, Sentinel and Traefik versions of the `upstream.json` it was synced from. An instance takes the newest version of its channel and the more stable ones +- Every stable sync also sets the `coolify` and `traefik` keys, which older instances read, to the stable versions, so older instances upgrade to a version that reads the blocks Cloudflare keeps a file of the CDN for up to 10 minutes. To clear the cache sooner, for example once the pull request of the stable channel is merged and deployed, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` in `.env` or the environment, with a token that can purge the cache of the zone: diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 97429cd904..3e5f2b19a8 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -52,7 +52,7 @@ class UpdateCoolify $latest = fetch_latest_versions(tries: $manual_update ? 1 : 3); if ($latest !== null) { - $this->latestVersion = data_get($latest, 'versions.coolify.v4.version'); + $this->latestVersion = data_get($latest, 'versions.coolify.version'); $this->upgradeScriptUrl = $latest['upgrade_script_url']; } else { // Fallback to cache if CDN unavailable @@ -71,6 +71,7 @@ class UpdateCoolify } $this->latestVersion = $cacheVersion; + $this->upgradeScriptUrl = data_get(get_versions_data(), 'upgrade_script_url'); Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [ 'version' => $cacheVersion, ]); @@ -92,6 +93,7 @@ class UpdateCoolify } $this->latestVersion = $cacheVersion; + $this->upgradeScriptUrl = data_get(get_versions_data(), 'upgrade_script_url'); Log::warning('Failed to fetch fresh version from CDN, using validated cache', [ 'error' => $e->getMessage(), 'version' => $cacheVersion, diff --git a/app/Console/Commands/UpdateChannel.php b/app/Console/Commands/UpdateChannel.php new file mode 100644 index 0000000000..6dba32b591 --- /dev/null +++ b/app/Console/Commands/UpdateChannel.php @@ -0,0 +1,43 @@ +argument('channel'); + if (! in_array($channel, ['stable', 'preview', 'nightly'], true)) { + $this->error("{$channel} is not an update channel, expected stable, preview or nightly"); + + return self::FAILURE; + } + + $settings = instanceSettings(); + $settings->switchUpdateChannel($channel); + $settings->save(); + invalidate_versions_cache(); + $this->info("The instance follows the {$channel} channel"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index f2573496ae..87e4899f21 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -132,7 +132,7 @@ class SentinelController extends Controller Cache::put(Server::sentinelReportedVersionCacheKey($server->id), $runningVersion, now()->addHours(2)); - $latestVersion = data_get(get_versions_data(), 'coolify.sentinel.version'); + $latestVersion = data_get(get_versions_data(), 'sentinel.version'); if (blank($latestVersion) || version_compare($runningVersion, $latestVersion, '>=')) { return; } diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 39dcc4519c..a98b400b08 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -17,7 +17,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue /** A manual check runs in the web request, so it does not wait for CDN retries. */ public int $cdnTries = 3; - public function __construct(int $cdnTries = 3, public bool $channelChanged = false) + public function __construct(int $cdnTries = 3) { $this->cdnTries = $cdnTries; } @@ -31,14 +31,14 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue if (isDev() || isCloud()) { return false; } - $versions = refresh_versions_file($this->cdnTries, $this->channelChanged); + $versions = refresh_versions_file($this->cdnTries); if ($versions === null) { return false; } CheckTraefikVersionJob::dispatch(); - $latestVersion = data_get($versions, 'coolify.v4.version'); + $latestVersion = data_get($versions, 'coolify.version'); $newVersionAvailable = instanceSettings()->update_channel !== 'custom' && version_compare($latestVersion, config('constants.coolify.version'), '>'); instanceSettings()->update(['new_version_available' => $newVersionAvailable]); diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 87fbbd3b7c..c95dcc9e2f 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -73,17 +73,15 @@ class Updates extends Component $this->settings->update_check_frequency = $this->update_check_frequency; $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; $this->settings->docker_registry_url = $validated['docker_registry_url']; - $this->settings->update_channel = $validated['update_channel']; + $this->settings->switchUpdateChannel($validated['update_channel']); $this->settings->update_custom_tag = $validated['update_custom_tag']; $this->syncRegistryUrlToEnv($validated['docker_registry_url']); $channelChanged = $this->settings->isDirty(['update_channel', 'update_custom_tag']); - if ($this->settings->isDirty('update_channel')) { - $this->settings->previous_update_channel = $this->channelFollowedUntilRelease($this->settings->getOriginal('update_channel'), $validated['update_channel']); - } $changedFields = auditChangedFields($this->settings); $this->settings->save(); if ($channelChanged) { - (new CheckForUpdatesJob(cdnTries: 1, channelChanged: true))->handle(); + invalidate_versions_cache(); + (new CheckForUpdatesJob(cdnTries: 1))->handle(); $this->dispatch('updateAvailable'); } if ($changedFields !== []) { @@ -102,22 +100,6 @@ class Updates extends Component } } - /** - * After a switch to a more stable channel, the instance keeps the updates of the channel it left until the stable release of its version is out. - */ - private function channelFollowedUntilRelease(?string $from, string $to): ?string - { - $channels = ['stable', 'preview', 'nightly']; - $fromRank = array_search($from, $channels, true); - $toRank = array_search($to, $channels, true); - if ($fromRank === false || $toRank === false || $toRank >= $fromRank) { - return null; - } - $followed = $this->settings->getOriginal('previous_update_channel'); - - return array_search($followed, $channels, true) > $fromRank ? $followed : $from; - } - protected function syncRegistryUrlToEnv(string $registryUrl): void { if (! $this->server) { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index b95328b889..c8ad482d05 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -146,6 +146,28 @@ class InstanceSettings extends Model return random_int(0, 59).' 0 * * *'; } + /** + * After a switch to a more stable channel, the instance keeps the updates of the channel it left until the stable release of its version is out. + */ + public function switchUpdateChannel(string $channel): void + { + $from = $this->getOriginal('update_channel'); + $this->update_channel = $channel; + if ($channel === $from) { + return; + } + + $channels = ['stable', 'preview', 'nightly']; + $fromRank = array_search($from, $channels, true); + $toRank = array_search($channel, $channels, true); + $followed = $this->getOriginal('previous_update_channel'); + $this->previous_update_channel = match (true) { + $fromRank === false || $toRank === false || $toRank >= $fromRank => null, + array_search($followed, $channels, true) > $fromRank => $followed, + default => $from, + }; + } + public function isPasswordRegistrationAllowed(): bool { if (! $this->is_registration_enabled) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 10255bcebd..4d83e03a31 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -715,14 +715,14 @@ function get_route_parameters(): array function get_latest_sentinel_version(): string { // Read the local versions.json, which CheckForUpdatesJob refreshes every hour. Do not ask the CDN for each server. - return data_get(get_versions_data(), 'coolify.sentinel.version') ?? '0.0.0'; + return data_get(get_versions_data(), 'sentinel.version') ?? '0.0.0'; } function get_latest_version_of_coolify(): string { try { $versions = get_versions_data(); - return data_get($versions, 'coolify.v4.version', '0.0.0'); + return data_get($versions, 'coolify.version', '0.0.0'); } catch (Throwable $e) { return '0.0.0'; diff --git a/bootstrap/helpers/versions.php b/bootstrap/helpers/versions.php index dba3959ce9..73377fb46e 100644 --- a/bootstrap/helpers/versions.php +++ b/bootstrap/helpers/versions.php @@ -9,24 +9,30 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; /** - * Get cached versions data from versions.json. + * Get the versions of the channel this instance follows: its Coolify version and the helper, Sentinel and Traefik versions released with it. * * This function provides a centralized, cached access point for all - * version data in the application. Data is cached in Redis for 1 hour - * and shared across all servers in the cluster. + * version data in the application. It reads the copy of versions.json of the CDN + * that refresh_versions_file() keeps on this node, or the upstream.json of the image until then. + * Data is cached in Redis for 1 hour and shared across all servers in the cluster. * - * @return array|null The versions data array, or null if file doesn't exist + * @return array{coolify: array{version: string}, helper?: array{version: string}, sentinel?: array{version: string}, traefik?: array} */ -function get_versions_data(): ?array +function get_versions_data(): array { - return Cache::remember('coolify:versions:all', 3600, function () { - $versionsPath = File::exists(storage_path('app/versions.json')) ? storage_path('app/versions.json') : base_path('versions.json'); - + return Cache::remember('coolify:versions', 3600, function () { + $versionsPath = storage_path('app/versions.json'); if (! File::exists($versionsPath)) { - return null; + return ['coolify' => ['version' => config('constants.coolify.version')]] + json_decode(File::get(base_path('upstream.json')), true); + } + $channels = json_decode(File::get($versionsPath), true); + $channel = newest_update_channel($channels); + $versions = $channels[$channel] + ['upgrade_script_url' => upgrade_script_url($channel)]; + if (version_compare(config('constants.coolify.version'), $versions['coolify']['version'], '>')) { + $versions['coolify']['version'] = config('constants.coolify.version'); } - return json_decode(File::get($versionsPath), true); + return $versions; }); } @@ -54,7 +60,7 @@ function get_traefik_versions(): ?array */ function invalidate_versions_cache(): void { - Cache::forget('coolify:versions:all'); + Cache::forget('coolify:versions'); } /** @@ -89,41 +95,41 @@ function cdn_retry_delay_ms(int $attempt, Throwable $exception): int } /** - * @return array{versions: array, upgrade_script_url: string}|null + * Fetch versions.json from the CDN and take the block of the newest Coolify version this instance takes. + * Only the stable, preview and nightly keys are read, the coolify and traefik keys are for older instances. + * + * @return array{channels: array, versions: array{coolify: array{version: string}}, upgrade_script_url: string}|null */ function fetch_latest_versions(int $tries = 3): ?array { - $settings = instanceSettings(); - $cdnUrl = dirname(config('constants.coolify.versions_url')); - $channel = 'stable'; - if ($settings->update_channel !== 'stable' || $settings->previous_update_channel !== null) { - $response = cdn_http($tries)->get("{$cdnUrl}/release.json"); - $release = $response->json(); - if ($response->failed() || ! is_array($release)) { - return null; - } - $channel = newest_update_channel(array_map(fn ($channel) => data_get($channel, 'coolify'), $release)); - } - - $channelUrl = $channel === 'stable' ? null : "{$cdnUrl}/{$channel}"; - $response = cdn_http($tries)->get($channelUrl ? "{$channelUrl}/versions.json" : config('constants.coolify.versions_url')); - $versions = $response->json(); - if ($response->failed() || ! is_string(data_get($versions, 'coolify.v4.version'))) { + $response = cdn_http($tries)->get(config('constants.coolify.versions_url')); + $channels = $response->json(); + if ($response->failed() || ! is_array($channels)) { return null; } + $channel = newest_update_channel($channels); return [ - 'versions' => $versions, - 'upgrade_script_url' => $channelUrl ? "{$channelUrl}/upgrade.sh" : config('constants.coolify.upgrade_script_url'), + 'channels' => $channels, + 'versions' => $channels[$channel], + 'upgrade_script_url' => upgrade_script_url($channel), ]; } +/** + * The upgrade script of an update channel on the CDN. + */ +function upgrade_script_url(string $channel): string +{ + return $channel === 'stable' ? config('constants.coolify.upgrade_script_url') : dirname(config('constants.coolify.versions_url'))."/{$channel}/upgrade.sh"; +} + /** * The channel of the newest Coolify version this instance takes: its update channel and the more stable ones. * After a switch to a more stable channel, also the channel it left, below the stable release of the running version, * so an instance on a nightly of 4.6.0 gets the nightlies of 4.6.0 until 4.6.0 is released. * - * @param array $versions The Coolify version of each channel + * @param array $versions The versions.json of the CDN */ function newest_update_channel(array $versions): string { @@ -134,8 +140,8 @@ function newest_update_channel(array $versions): string $release = preg_replace('/^(\d+\.\d+\.\d+).*/', '$1', config('constants.coolify.version')); $newest = 'stable'; foreach ($channels as $rank => $channel) { - $version = $versions[$channel] ?? null; - if (! is_string($version) || version_compare($version, (string) ($versions[$newest] ?? ''), '<=')) { + $version = data_get($versions, "{$channel}.coolify.version"); + if (! is_string($version) || version_compare($version, (string) data_get($versions, "{$newest}.coolify.version"), '<=')) { continue; } if ($rank <= $follows || ($rank <= $followsUntilRelease && version_compare($version, $release, '<'))) { @@ -147,62 +153,50 @@ function newest_update_channel(array $versions): string } /** - * Fetch versions.json from the CDN and replace the local file of this node with it in one step. - * The Coolify version never goes below the running version, or below the cached one while the channel stays. - * When the CDN fails, the local file stays as it is. After a channel change it is removed instead, - * so the versions of the image apply until the next check and not the version of the old channel. + * Fetch versions.json from the CDN and replace the copy of it on this node in one step. + * The copy stays when the CDN serves an older Coolify version for this instance than the copy has, or when the CDN fails. * - * @return array|null The written versions data, or null if the CDN fetch failed + * @return array|null The versions of the channel of this instance, or null if the CDN fetch failed */ -function refresh_versions_file(int $tries = 3, bool $channelChanged = false): ?array +function refresh_versions_file(int $tries = 3): ?array { $versionsPath = storage_path('app/versions.json'); - $versions = fetch_latest_versions($tries)['versions'] ?? null; - if ($versions === null) { + $latest = fetch_latest_versions($tries); + if ($latest === null) { Log::warning('Could not fetch versions.json from the CDN, using the local file'); - if ($channelChanged) { - File::delete($versionsPath); - invalidate_versions_cache(); - } return null; } - $latestVersion = data_get($versions, 'coolify.v4.version'); + $latestVersion = data_get($latest, 'versions.coolify.version'); $currentVersion = config('constants.coolify.version'); $existingCoolifyVersion = null; - if (! $channelChanged && File::exists($versionsPath)) { - $existingVersions = json_decode(File::get($versionsPath), true); - $existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version'); + if (File::exists($versionsPath)) { + $existingChannels = json_decode(File::get($versionsPath), true); + $existingCoolifyVersion = data_get($existingChannels, newest_update_channel($existingChannels).'.coolify.version'); } - $bestVersion = $latestVersion; - - if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) { + if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $latestVersion, '>')) { Log::warning('CDN served older Coolify version than cache', [ 'cdn_version' => $latestVersion, 'cached_version' => $existingCoolifyVersion, 'current_version' => $currentVersion, ]); - $bestVersion = $existingCoolifyVersion; + + return get_versions_data(); } - if (version_compare($bestVersion, $currentVersion, '<')) { + if (version_compare($latestVersion, $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::replace($versionsPath, json_encode($versions, JSON_PRETTY_PRINT)); + File::replace($versionsPath, json_encode($latest['channels'], JSON_PRETTY_PRINT)); invalidate_versions_cache(); + $versions = get_versions_data(); update_helper_version($versions); return $versions; @@ -213,7 +207,7 @@ function refresh_versions_file(int $tries = 3, bool $channelChanged = false): ?a */ function update_helper_version(?array $versions): void { - $latestVersion = data_get($versions, 'coolify.helper.version'); + $latestVersion = data_get($versions, 'helper.version'); $settings = instanceSettings(); if ($latestVersion && version_compare($latestVersion, (string) $settings->helper_version, '>')) { $settings->update(['helper_version' => $latestVersion]); diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index fcd20d01e0..e53d73e82c 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -204,8 +204,6 @@ COPY --chown=www-data:www-data artisan artisan COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml COPY --chown=www-data:www-data changelogs/ ./changelogs/ COPY --chown=www-data:www-data release.json upstream.json ./ -RUN jq --arg version "$COOLIFY_VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > versions.json \ - && chown www-data:www-data versions.json RUN composer dump-autoload diff --git a/scripts/install.sh b/scripts/install.sh index eab94dbeae..9427c17d18 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,11 +11,22 @@ ## AUTOUPDATE - Set to "false" to disable auto-updates ## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io) +## Arguments that can be passed: +## --preview or --nightly - Install the newest version of the preview or nightly channel and switch the instance to that update channel + set -e # Exit immediately if a command exits with a non-zero status ## $1 could be empty, so we need to disable this check #set -u # Treat unset variables as an error and exit set -o pipefail # Cause a pipeline to return the status of the last command that exited with a non-zero status CDN="https://cdn.coollabs.io/coolify" +CHANNEL="stable" +case "$1" in +--preview | --nightly) + CHANNEL="${1#--}" + CDN="https://cdn.coollabs.io/coolify/$CHANNEL" + shift + ;; +esac DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') @@ -518,9 +529,10 @@ if [ "$OS_TYPE" = 'amzn' ]; then fi # Fetch versions.json once and parse all values from it -VERSIONS_JSON=$(curl -L --silent $CDN/versions.json) -LATEST_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"v4"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1) -LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"helper"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1) +VERSIONS_JSON=$(curl -L --silent https://cdn.coollabs.io/coolify/versions.json) +CHANNEL_VERSIONS=$(echo "$VERSIONS_JSON" | sed -n "/^ \"$CHANNEL\": {/,/^ }/p") +LATEST_VERSION=$(echo "$CHANNEL_VERSIONS" | sed -n '/"coolify"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1) +LATEST_HELPER_VERSION=$(echo "$CHANNEL_VERSIONS" | sed -n '/"helper"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1) if [ -z "$LATEST_HELPER_VERSION" ]; then LATEST_HELPER_VERSION=latest @@ -1129,6 +1141,11 @@ if [ "$HEALTH" != "healthy" ]; then fi step_done +if [ "$CHANNEL" != "stable" ]; then + docker exec coolify php artisan app:update-channel "$CHANNEL" || warn "Could not switch the instance to the $CHANNEL update channel, switch it on the update page" + ui_flush_warnings +fi + if [ "$UI_TTY" = true ]; then printf '%s\n' "$(ui_bar 100 "done")" >&3 fi diff --git a/scripts/sync-cdn.sh b/scripts/sync-cdn.sh index 067c428520..1070dab06e 100755 --- a/scripts/sync-cdn.sh +++ b/scripts/sync-cdn.sh @@ -6,7 +6,7 @@ CHANNEL="${1:-}" VERSION="${2:-}" CDN_REPOSITORY=coollabsio/coollabs-cdn CDN_URL=https://cdn.coollabs.io -FILES=versions.json,releases.json,docker-compose.yml,docker-compose.prod.yml,.env.production,install.sh,upgrade.sh,upgrade-postgres.sh,patch.sh,service-templates-latest.json +FILES=releases.json,docker-compose.yml,docker-compose.prod.yml,.env.production,install.sh,upgrade.sh,upgrade-postgres.sh,patch.sh,service-templates-latest.json WORK_DIR="$(mktemp -d)" BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' RESET=$'\033[0m' @@ -29,10 +29,9 @@ main() { return fi select_channel - write_versions fetch_releases clone_cdn - write_release + write_versions commit_channel publish_channel } @@ -50,11 +49,6 @@ select_channel() { info "syncing $channel_url to Coolify $BOLD$VERSION$RESET" } -write_versions() { - jq --indent 4 --arg version "$VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > "$WORK_DIR/versions.json" - ok "versions.json names Coolify $VERSION and the helper, Sentinel and Traefik versions of upstream.json" -} - fetch_releases() { gh api "repos/coollabsio/coolify/releases?per_page=30" | jq . > "$WORK_DIR/releases.json" ok "releases.json lists the $(jq length "$WORK_DIR/releases.json") newest GitHub releases" @@ -65,13 +59,17 @@ clone_cdn() { ok "cloned $CDN_REPOSITORY" } -write_release() { - local release="$WORK_DIR/cdn/json/coolify/release.json" - [ -f "$release" ] || echo '{}' > "$release" - jq --tab --arg channel "$channel" --arg version "$VERSION" '.[$channel].coolify = $version | {stable, preview, nightly} | with_entries(select(.value))' \ - "$release" > "$WORK_DIR/release.json" - cp "$WORK_DIR/release.json" "$release" - ok "release.json sets the $channel version $BOLD$VERSION$RESET" +write_versions() { + local versions="$WORK_DIR/cdn/json/coolify/versions.json" + [ -f "$versions" ] || echo '{}' > "$versions" + jq --indent 4 --arg channel "$channel" --arg version "$VERSION" --slurpfile upstream upstream.json ' + .[$channel] = ({coolify: {$version}} + $upstream[0]) + | if $channel == "stable" then .coolify = ({v4: {$version}} + ($upstream[0] | del(.traefik))) | .traefik = $upstream[0].traefik else . end + | {stable, preview, nightly, coolify, traefik} | with_entries(select(.value)) + ' "$versions" > "$WORK_DIR/versions.json" + cp "$WORK_DIR/versions.json" "$versions" + ok "versions.json sets the $channel version $BOLD$VERSION$RESET with the helper, Sentinel and Traefik versions of upstream.json" + [ "$channel" != stable ] || ok "versions.json sets the coolify and traefik keys of older instances to the same versions" } commit_channel() { @@ -80,7 +78,10 @@ commit_channel() { for name in ${FILES//,/ }; do channel_file "$name" > "$WORK_DIR/cdn/$channel_path/$name" done - cdn add "$channel_path" json/coolify/release.json + if [ "$channel" != stable ]; then + cdn rm --quiet --ignore-unmatch "$channel_path/versions.json" + fi + cdn add "$channel_path" json/coolify/versions.json if cdn diff --cached --quiet; then ok "$channel_path on $CDN_REPOSITORY is up to date" exit 0 @@ -98,7 +99,7 @@ publish_channel() { ok "pushed the $channel channel to main of $CDN_REPOSITORY" return fi - previous="$(cdn show HEAD~1:json/coolify/versions.json 2> /dev/null | jq -r '.coolify.v4.version // empty' || true)" + previous="$(cdn show HEAD~1:json/coolify/versions.json 2> /dev/null | jq -r '.stable.coolify.version // .coolify.v4.version // empty' || true)" [ "$previous" = "$VERSION" ] || from="${previous:+from Coolify $previous to }" branch="sync/stable-$(date -u +%Y%m%d%H%M%S)" cdn push --quiet origin "HEAD:refs/heads/$branch" @@ -124,9 +125,9 @@ purge_cache() { channel_file() { case "$1" in - versions.json | releases.json) cat "$WORK_DIR/$1" ;; + releases.json) cat "$WORK_DIR/$1" ;; service-templates-latest.json) cat "templates/$1" ;; - *.sh) sed "s|^CDN=\"$CDN_URL/coolify\"$|CDN=\"$channel_url\"|" "scripts/$1" ;; + *.sh) sed -e "s|^CDN=\"$CDN_URL/coolify\"$|CDN=\"$channel_url\"|" -e "s|^CHANNEL=\"stable\"$|CHANNEL=\"$channel\"|" "scripts/$1" ;; *) cat "$1" ;; esac } diff --git a/tests/Feature/CheckForUpdatesTraefikTest.php b/tests/Feature/CheckForUpdatesTraefikTest.php index 3cac6349f8..9a769f49a8 100644 --- a/tests/Feature/CheckForUpdatesTraefikTest.php +++ b/tests/Feature/CheckForUpdatesTraefikTest.php @@ -5,22 +5,21 @@ use App\Jobs\CheckTraefikVersionJob; use App\Models\InstanceSettings; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); +afterEach(function () { + @unlink(storage_path('app/versions.json')); +}); + it('checks installed Traefik versions after refreshing available versions', function () { Bus::fake(); Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.10']], - 'traefik' => ['v3.7' => '3.7.13'], + 'stable' => ['coolify' => ['version' => '4.0.10'], 'traefik' => ['v3.7' => '3.7.13']], ]), ]); - File::shouldReceive('exists')->andReturn(false); - File::shouldReceive('replace')->once(); - InstanceSettings::forceCreate(['id' => 0]); config([ @@ -32,4 +31,5 @@ it('checks installed Traefik versions after refreshing available versions', func (new CheckForUpdatesJob)->handle(); Bus::assertDispatched(CheckTraefikVersionJob::class); + expect(get_traefik_versions())->toBe(['v3.7' => '3.7.13']); }); diff --git a/tests/Feature/HelperVersionTest.php b/tests/Feature/HelperVersionTest.php index 4f48acfa75..757003c4f2 100644 --- a/tests/Feature/HelperVersionTest.php +++ b/tests/Feature/HelperVersionTest.php @@ -12,6 +12,10 @@ use Illuminate\Support\Sleep; uses(RefreshDatabase::class); +afterEach(function () { + @unlink(storage_path('app/versions.json')); +}); + beforeEach(function () { InstanceSettings::forceCreate(['id' => 0]); @@ -24,7 +28,7 @@ beforeEach(function () { it('uses a newer helper version from the local versions file without asking the CDN', function () { Http::preventStrayRequests(); - Cache::put('coolify:versions:all', ['coolify' => ['helper' => ['version' => '1.0.18']]], 3600); + Cache::put('coolify:versions', ['coolify' => ['version' => '4.0.0'], 'helper' => ['version' => '1.0.18']], 3600); (new CheckHelperImageJob)->handle(); @@ -33,18 +37,17 @@ it('uses a newer helper version from the local versions file without asking the }); it('stores the helper version when the update check refreshes versions.json from the CDN', function () { - File::partialMock()->shouldReceive('replace')->once()->with(storage_path('app/versions.json'), Mockery::type('string')); config(['constants.coolify.version' => '4.0.0']); Http::preventStrayRequests(); Http::fake([ 'https://cdn.example.com/coolify/versions.json' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.0'], 'helper' => ['version' => '1.0.19']], + 'stable' => ['coolify' => ['version' => '4.0.0'], 'helper' => ['version' => '1.0.25']], ]), ]); refresh_versions_file(); - expect(InstanceSettings::findOrFail(0)->helper_version)->toBe('1.0.19'); + expect(InstanceSettings::findOrFail(0)->helper_version)->toBe('1.0.25'); }); it('does not use a stored helper version older than the bundled version', function () { @@ -58,7 +61,7 @@ it('keeps the stored helper version and does not notify when the CDN is unavaila InstanceSettings::findOrFail(0)->update(['helper_version' => '1.0.17']); Notification::fake(); Sleep::fake(); - File::partialMock()->shouldNotReceive('put'); + File::partialMock()->shouldNotReceive('replace'); Http::preventStrayRequests(); Http::fake([ 'https://cdn.example.com/coolify/versions.json' => Http::response('error code: 1200', 503, ['Retry-After' => '60']), diff --git a/tests/Feature/InstanceSettingsAuditTest.php b/tests/Feature/InstanceSettingsAuditTest.php index 48521548db..6441be372b 100644 --- a/tests/Feature/InstanceSettingsAuditTest.php +++ b/tests/Feature/InstanceSettingsAuditTest.php @@ -163,7 +163,7 @@ test('resend settings save is audited without the api key', function () { test('manual instance upgrade is audited', function () { Bus::fake(); config()->set('constants.coolify.version', '4.0.0'); - Cache::put('coolify:versions:all', ['coolify' => ['v4' => ['version' => '4.0.1']]], 3600); + Cache::put('coolify:versions', ['coolify' => ['version' => '4.0.1']], 3600); Livewire::test(Upgrade::class)->call('upgrade'); diff --git a/tests/Feature/SentinelVersionReportTest.php b/tests/Feature/SentinelVersionReportTest.php index 9b9227b6e7..c02fda0ff8 100644 --- a/tests/Feature/SentinelVersionReportTest.php +++ b/tests/Feature/SentinelVersionReportTest.php @@ -14,7 +14,7 @@ beforeEach(function () { Queue::fake(); Cache::flush(); - Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.2']]], 3600); + Cache::put('coolify:versions', ['sentinel' => ['version' => '1.0.2']], 3600); $user = User::factory()->create(); $this->server = Server::factory()->create(['team_id' => $user->teams()->first()->id]); diff --git a/tests/Feature/Server/CheckAndStartSentinelJobTest.php b/tests/Feature/Server/CheckAndStartSentinelJobTest.php index e79fe1981c..ce561d3872 100644 --- a/tests/Feature/Server/CheckAndStartSentinelJobTest.php +++ b/tests/Feature/Server/CheckAndStartSentinelJobTest.php @@ -22,7 +22,7 @@ beforeEach(function () { config()->set('constants.ssh.max_retries', 1); config()->set('constants.ssh.mux_enabled', false); Http::preventStrayRequests(); - Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.0']]], 3600); + Cache::put('coolify:versions', ['sentinel' => ['version' => '1.0.0']], 3600); $user = User::factory()->create(); $team = $user->teams()->first(); @@ -110,7 +110,7 @@ it('does not restart a Sentinel whose health check is still starting', function it('restarts a running Sentinel that stopped pushing, at most once per hour', function () { Cache::flush(); - Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.0']]], 3600); + Cache::put('coolify:versions', ['sentinel' => ['version' => '1.0.0']], 3600); $this->server->forceFill(['sentinel_updated_at' => now()->subMinutes(15), 'sentinel_waiting_since' => null])->save(); fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 2 hours (healthy)'), @@ -124,7 +124,7 @@ it('restarts a running Sentinel that stopped pushing, at most once per hour', fu it('does not restart a Sentinel that started recently and has not pushed yet', function () { Cache::flush(); - Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.0']]], 3600); + Cache::put('coolify:versions', ['sentinel' => ['version' => '1.0.0']], 3600); $this->server->forceFill(['sentinel_updated_at' => now()->subDays(5), 'sentinel_waiting_since' => now()->subMinutes(3)])->save(); fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 3 minutes (healthy)'), diff --git a/tests/Feature/SettingsUpdatesCheckNowTest.php b/tests/Feature/SettingsUpdatesCheckNowTest.php index af12f4cc85..d09760d10f 100644 --- a/tests/Feature/SettingsUpdatesCheckNowTest.php +++ b/tests/Feature/SettingsUpdatesCheckNowTest.php @@ -15,6 +15,10 @@ use Livewire\Livewire; uses(RefreshDatabase::class); +afterEach(function () { + @unlink(storage_path('app/versions.json')); +}); + beforeEach(function () { Queue::fake([CheckTraefikVersionJob::class]); config()->set('constants.coolify.version', '4.4.6'); @@ -41,8 +45,7 @@ it('reports a failed CDN request instead of "No new version available"', functio }); it('reports a new version when the CDN has a newer version', function () { - Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.4.7']]])]); - File::partialMock()->shouldReceive('replace')->with(storage_path('app/versions.json'), Mockery::any())->once(); + Http::fake(['*' => Http::response(['stable' => ['coolify' => ['version' => '4.4.7']]])]); Livewire::test(Updates::class) ->call('checkManually') diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index 320993b9c5..955dbf6c8a 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -17,7 +17,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); it('shows patch and minor upgrade warnings on the first proxy render', function () { - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => [ 'v3.7' => '3.7.13', 'v3.6' => '3.6.25', @@ -50,7 +50,7 @@ it('shows patch and minor upgrade warnings on the first proxy render', function }); it('shows a warning from the saved image before version detection finishes', function () { - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => ['v3.7' => '3.7.13', 'v3.6' => '3.6.25'], ]); @@ -96,7 +96,7 @@ it('does not treat an unapplied image as the running Traefik version', function }); it('ignores stale minor upgrade information for the detected Traefik version', function () { - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => [ 'v3.7' => '3.7.8', 'v3.6' => '3.6.23', @@ -121,7 +121,7 @@ it('ignores stale minor upgrade information for the detected Traefik version', f }); it('does not offer the Traefik branch already configured on a running proxy', function () { - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => [ 'v3.7' => '3.7.8', 'v3.6' => '3.6.23', @@ -154,7 +154,7 @@ YAML; }); it('still offers a newer Traefik branch than the configured image', function () { - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => [ 'v3.7' => '3.7.8', 'v3.6' => '3.6.23', @@ -321,7 +321,7 @@ it('clears Traefik version state when the proxy changes', function () { it('cleans stale Traefik version state while selecting servers to check', function () { Bus::fake(); - Cache::put('coolify:versions:all', [ + Cache::put('coolify:versions', [ 'traefik' => ['v3.7' => '3.7.8'], ]); diff --git a/tests/Feature/UpgradeComponentTest.php b/tests/Feature/UpgradeComponentTest.php index d3379e78c8..3c04306c1d 100644 --- a/tests/Feature/UpgradeComponentTest.php +++ b/tests/Feature/UpgradeComponentTest.php @@ -17,12 +17,10 @@ it('initializes latest version during mount from cached versions data', function Cache::shouldReceive('remember') ->once() - ->with('coolify:versions:all', 3600, Mockery::type(Closure::class)) + ->with('coolify:versions', 3600, Mockery::type(Closure::class)) ->andReturn([ 'coolify' => [ - 'v4' => [ - 'version' => '4.0.0-beta.999', - ], + 'version' => '4.0.0-beta.999', ], ]); @@ -42,7 +40,7 @@ it('falls back to 0.0.0 during mount when cached versions data is unavailable', Cache::shouldReceive('remember') ->once() - ->with('coolify:versions:all', 3600, Mockery::type(Closure::class)) + ->with('coolify:versions', 3600, Mockery::type(Closure::class)) ->andReturn(null); Livewire::test(Upgrade::class) @@ -58,12 +56,10 @@ it('clears stale upgrade availability when current version already matches lates Cache::shouldReceive('remember') ->once() - ->with('coolify:versions:all', 3600, Mockery::type(Closure::class)) + ->with('coolify:versions', 3600, Mockery::type(Closure::class)) ->andReturn([ 'coolify' => [ - 'v4' => [ - 'version' => '4.0.0-beta.999', - ], + 'version' => '4.0.0-beta.999', ], ]); @@ -83,12 +79,10 @@ it('clears stale upgrade availability when current version is newer than cached Cache::shouldReceive('remember') ->once() - ->with('coolify:versions:all', 3600, Mockery::type(Closure::class)) + ->with('coolify:versions', 3600, Mockery::type(Closure::class)) ->andReturn([ 'coolify' => [ - 'v4' => [ - 'version' => '4.0.0-beta.999', - ], + 'version' => '4.0.0-beta.999', ], ]); diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php index b1f69062d9..cdb688f9a0 100644 --- a/tests/Unit/CheckForUpdatesJobTest.php +++ b/tests/Unit/CheckForUpdatesJobTest.php @@ -23,146 +23,51 @@ beforeEach(function () { }); afterEach(function () { + @unlink(storage_path('app/versions.json')); Mockery::close(); }); -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(storage_path('app/versions.json')) - ->andReturn(true); - - File::shouldReceive('get') - ->with(storage_path('app/versions.json')) - ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]])); - - File::shouldReceive('replace') - ->once() - ->with(storage_path('app/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(); - +it('keeps the local versions.json when the CDN serves an older Coolify version', function () { + Http::fake(['*' => Http::response(['stable' => ['coolify' => ['version' => '4.0.0'], 'traefik' => ['v3.5' => '3.5.6']]], 200)]); + File::put(storage_path('app/versions.json'), json_encode(['stable' => ['coolify' => ['version' => '4.0.10']]])); 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(storage_path('app/versions.json')) - ->andReturn(true); - - File::shouldReceive('get') - ->with(storage_path('app/versions.json')) - ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]])); - - File::shouldReceive('replace') - ->once() - ->with(storage_path('app/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']); - Log::shouldReceive('warning') ->once() ->with('CDN served older Coolify version than cache', Mockery::type('array')); + expect(refresh_versions_file())->toMatchArray(['coolify' => ['version' => '4.0.10']]) + ->and(json_decode(File::get(storage_path('app/versions.json')), true))->toBe(['stable' => ['coolify' => ['version' => '4.0.10']]]); +}); + +it('never offers a version below the running version', function () { + Http::fake(['*' => Http::response(['stable' => ['coolify' => ['version' => '4.0.0']]], 200)]); + config(['constants.coolify.version' => '4.0.10']); + 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(); + expect(data_get(refresh_versions_file(), 'coolify.version'))->toBe('4.0.10'); }); -it('preserves other component versions when preventing Coolify downgrade', function () { - // CDN has older Coolify but newer Traefik +it('keeps the CDN versions of the other components when it keeps the running Coolify version', function () { Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.0']], - 'traefik' => ['v3.6' => '3.6.2'], - 'sentinel' => ['version' => '1.0.5'], + 'stable' => [ + 'coolify' => ['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('replace') - ->once() - ->with(storage_path('app/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']); - Log::shouldReceive('warning') - ->once() - ->with('CDN served older Coolify version than cache', Mockery::type('array')); - - 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(); + expect(refresh_versions_file())->toMatchArray([ + 'coolify' => ['version' => '4.0.10'], + 'traefik' => ['v3.6' => '3.6.2'], + 'sentinel' => ['version' => '1.0.5'], + ]); }); it('keeps the local versions.json when the CDN fetch fails', function () { diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 5f6e12b206..821dacbdc9 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -1,13 +1,34 @@ ['coolify' => ['version' => '4.5.1']] + $upstream, + 'preview' => ['coolify' => ['version' => '4.6.0-beta.1']] + $upstream, + 'nightly' => ['coolify' => ['version' => '4.6.0-beta.1.20261027.1']] + $upstream, + 'coolify' => ['v4' => ['version' => '4.5.1']], + ], JSON_PRETTY_PRINT); + $parse = implode("\n", preg_grep('/^(CHANNEL_VERSIONS|LATEST_(HELPER_)?VERSION)=\$\(echo "\$(VERSIONS_JSON|CHANNEL_VERSIONS)"/', file("{$root}/scripts/install.sh"))); + $output = shell_exec('CHANNEL='.escapeshellarg($channel).' VERSIONS_JSON='.escapeshellarg($versions).' bash -c '.escapeshellarg($parse."\necho \"\$LATEST_VERSION \$LATEST_HELPER_VERSION\"")); - expect(trim($output))->toBe("4.6.0-beta.1 {$helper}"); -}); + expect(trim($output))->toBe("{$version} {$upstream['helper']['version']}"); +})->with([ + 'stable' => ['stable', '4.5.1'], + 'preview' => ['preview', '4.6.0-beta.1'], + 'nightly' => ['nightly', '4.6.0-beta.1.20261027.1'], +]); + +it('selects the channel from the --preview or --nightly flag in install.sh', function (array $arguments, string $selection) { + preg_match('/^CDN=.*?^esac$/ms', file_get_contents(dirname(__DIR__, 2).'/scripts/install.sh'), $channelSelection); + $output = shell_exec('bash -c '.escapeshellarg($channelSelection[0]."\necho \"\$CHANNEL \$CDN \$1\"").' install.sh '.implode(' ', array_map('escapeshellarg', $arguments))); + + expect(trim($output))->toBe($selection); +})->with([ + 'stable' => [[], 'stable https://cdn.coollabs.io/coolify'], + 'preview' => [['--preview'], 'preview https://cdn.coollabs.io/coolify/preview'], + 'nightly with a version' => [['--nightly', '4.6.0-beta.1.20261027.1'], 'nightly https://cdn.coollabs.io/coolify/nightly 4.6.0-beta.1.20261027.1'], +]); it('builds the development helper image from the coolify-helper repository', function (string $file) { expect(file_get_contents(dirname(__DIR__, 2)."/{$file}"))->toContain('https://github.com/coollabsio/coolify-helper.git#main:src'); @@ -44,13 +65,13 @@ it('guides issue authors to the correct contribution branch', function () { ->toContain('Small fixes should target `main`; larger changes should target `next`'); }); -it('writes versions.json in the production image from upstream.json so version checks work right after an upgrade', function () { +it('ships release.json and upstream.json in the production image so version checks work right after an upgrade', function () { $dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile'); $dockerignore = file_get_contents(dirname(__DIR__, 2).'/.dockerignore'); expect($dockerfile) ->toContain('COPY --chown=www-data:www-data release.json upstream.json ./') - ->toContain('upstream.json > versions.json') + ->not->toContain('versions.json') ->and($dockerignore) ->not->toMatch('/^\/?(release|upstream)\.json$/m'); }); diff --git a/tests/Unit/UpdateChannelTest.php b/tests/Unit/UpdateChannelTest.php index a387128cbb..15c3533ce7 100644 --- a/tests/Unit/UpdateChannelTest.php +++ b/tests/Unit/UpdateChannelTest.php @@ -10,7 +10,6 @@ use App\Models\User; use App\Services\CoolifyUpgradeStatus; use App\Support\RemoteProcessCommand; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; @@ -37,7 +36,7 @@ beforeEach(function () { }); afterEach(function () { - File::delete(storage_path('app/versions.json')); + @unlink(storage_path('app/versions.json')); }); function updateChannelTestUse(string $channel, ?string $customTag = null, ?string $preview = '4.6.0-beta.2', string $nightly = '4.6.0-beta.1.20261027.3'): void @@ -45,14 +44,11 @@ function updateChannelTestUse(string $channel, ?string $customTag = null, ?strin InstanceSettings::findOrFail(0)->update(['update_channel' => $channel, 'update_custom_tag' => $customTag]); Once::flush(); Http::fake([ - 'cdn.example.com/coolify/release.json' => Http::response(array_filter([ - 'stable' => ['coolify' => '4.5.1'], - 'preview' => $preview ? ['coolify' => $preview] : null, - 'nightly' => ['coolify' => $nightly], + 'cdn.example.com/coolify/versions.json' => Http::response(array_filter([ + 'stable' => ['coolify' => ['version' => '4.5.1']], + 'preview' => $preview ? ['coolify' => ['version' => $preview]] : null, + 'nightly' => ['coolify' => ['version' => $nightly]], ])), - 'cdn.example.com/coolify/versions.json' => Http::response(['coolify' => ['v4' => ['version' => '4.5.1']]]), - 'cdn.example.com/coolify/preview/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $preview]]]), - 'cdn.example.com/coolify/nightly/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $nightly]]]), ]); } @@ -60,7 +56,7 @@ it('follows the newest version of the channel and the more stable ones', functio updateChannelTestUse($channel); expect(fetch_latest_versions())->toMatchArray([ - 'versions' => ['coolify' => ['v4' => ['version' => $version]]], + 'versions' => ['coolify' => ['version' => $version]], 'upgrade_script_url' => $upgradeScriptUrl, ]); })->with([ @@ -77,16 +73,16 @@ it('asks only the stable channel of the CDN on the stable channel', function () Http::assertSentCount(1); }); -it('skips a channel that release.json does not list yet', function () { +it('skips a channel that versions.json does not list yet', function () { updateChannelTestUse('nightly', preview: null, nightly: '4.6.0-alpha.0.20261027.1'); expect(fetch_latest_versions(tries: 1)['upgrade_script_url'])->toBe('https://cdn.example.com/coolify/nightly/upgrade.sh'); }); -it('checks nothing when release.json does not answer', function () { +it('checks nothing when versions.json does not answer', function () { InstanceSettings::findOrFail(0)->update(['update_channel' => 'nightly']); Once::flush(); - Http::fake(['cdn.example.com/coolify/release.json' => Http::response('', 404)]); + Http::fake(['cdn.example.com/coolify/versions.json' => Http::response('', 404)]); expect(fetch_latest_versions(tries: 1))->toBeNull(); }); @@ -168,12 +164,10 @@ it('keeps the nightlies of the running version after a switch to stable until it InstanceSettings::findOrFail(0)->update(['update_channel' => 'stable', 'previous_update_channel' => 'nightly']); Once::flush(); Http::fake([ - 'cdn.example.com/coolify/release.json' => Http::response(['stable' => ['coolify' => $stable], 'nightly' => ['coolify' => $nightly]]), - 'cdn.example.com/coolify/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $stable]]]), - 'cdn.example.com/coolify/nightly/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $nightly]]]), + 'cdn.example.com/coolify/versions.json' => Http::response(['stable' => ['coolify' => ['version' => $stable]], 'nightly' => ['coolify' => ['version' => $nightly]]]), ]); - expect(data_get(fetch_latest_versions(), 'versions.coolify.v4.version'))->toBe($version); + expect(data_get(fetch_latest_versions(), 'versions.coolify.version'))->toBe($version); })->with([ 'a newer nightly of 4.6.0' => ['4.5.1', '4.6.0-beta.2.20261028.1', '4.6.0-beta.2.20261028.1'], 'the stable release of 4.6.0' => ['4.6.0', '4.7.0-alpha.0.20261101.1', '4.6.0'], @@ -209,21 +203,22 @@ it('follows the channel it left after a switch to a more stable channel', functi 'nightly to a custom image' => ['nightly', 'custom', null], ]); -it('replaces the versions of the old channel after a channel change', function () { - config(['constants.coolify.version' => '4.5.1']); - File::put(storage_path('app/versions.json'), json_encode(['coolify' => ['v4' => ['version' => '4.6.0-beta.2.20261027.1']]])); - updateChannelTestUse('stable'); +it('switches the update channel with app:update-channel like the update page does', function (string $from, string $to, ?string $followed) { + InstanceSettings::findOrFail(0)->update(['update_channel' => $from]); + Once::flush(); - refresh_versions_file(tries: 1, channelChanged: true); + $this->artisan('app:update-channel', ['channel' => $to])->assertSuccessful(); - expect(data_get(json_decode(File::get(storage_path('app/versions.json')), true), 'coolify.v4.version'))->toBe('4.5.1'); -}); + expect(InstanceSettings::findOrFail(0)) + ->update_channel->toBe($to) + ->previous_update_channel->toBe($followed); +})->with([ + 'stable to nightly' => ['stable', 'nightly', null], + 'nightly to preview' => ['nightly', 'preview', 'nightly'], +]); -it('falls back to the versions of the image when the CDN fails after a channel change', function () { - File::put(storage_path('app/versions.json'), json_encode(['coolify' => ['v4' => ['version' => '4.6.0-beta.2.20261027.1']]])); - Http::fake(['*' => Http::response('', 500)]); +it('refuses an update channel that does not exist', function () { + $this->artisan('app:update-channel', ['channel' => 'custom'])->assertFailed(); - refresh_versions_file(tries: 1, channelChanged: true); - - expect(File::exists(storage_path('app/versions.json')))->toBeFalse(); + expect(InstanceSettings::findOrFail(0)->update_channel)->toBe('stable'); }); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php index c842dffec8..dc6fe4d247 100644 --- a/tests/Unit/UpdateCoolifyTest.php +++ b/tests/Unit/UpdateCoolifyTest.php @@ -99,7 +99,7 @@ function updateCoolifyTestPrepareUpgrade(): void Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.10']], + 'stable' => ['coolify' => ['version' => '4.0.10']], ], 200), ]); } @@ -123,7 +123,7 @@ it('validates cache against running version before fallback', function () { // Mock cache returning older version Cache::shouldReceive('remember') - ->andReturn(['coolify' => ['v4' => ['version' => '4.0.5']]]); + ->andReturn(['coolify' => ['version' => '4.0.5']]); config(['constants.coolify.version' => '4.0.10']); @@ -150,7 +150,7 @@ it('uses validated cache when CDN fails and cache is newer', function () { // Cache has newer version than current Cache::shouldReceive('remember') - ->andReturn(['coolify' => ['v4' => ['version' => '4.0.10']]]); + ->andReturn(['coolify' => ['version' => '4.0.10']]); config(['constants.coolify.version' => '4.0.5']); @@ -183,7 +183,7 @@ it('passes the saved registry URL to the upgrade script command', function () { Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.10']], + 'stable' => ['coolify' => ['version' => '4.0.10']], ], 200), ]); @@ -212,7 +212,7 @@ it('falls back to docker io for the upgrade script command when no registry is s Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.10']], + 'stable' => ['coolify' => ['version' => '4.0.10']], ], 200), ]); @@ -364,7 +364,7 @@ it('prevents downgrade even with manual update', function () { // CDN returns older version Http::fake([ '*' => Http::response([ - 'coolify' => ['v4' => ['version' => '4.0.0']], + 'stable' => ['coolify' => ['version' => '4.0.0']], ], 200), ]); @@ -396,7 +396,7 @@ it('does not start a second upgrade while a recent upgrade status is in progress 'constants.ssh.mux_enabled' => false, ]); updateCoolifyTestCreateRootServerAndSettings(); - Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.0.10']]], 200)]); + Http::fake(['*' => Http::response(['stable' => ['coolify' => ['version' => '4.0.10']]], 200)]); $action = updateCoolifyTestActionWithStatus('3|Pulling Docker images|'.now()->subMinutes(2)->toIso8601String()); expect(fn () => $action->handle(manual_update: true)) @@ -414,7 +414,7 @@ it('starts an upgrade when the previous upgrade status is older than the lock ex 'constants.ssh.mux_enabled' => false, ]); updateCoolifyTestCreateRootServerAndSettings(); - Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.0.10']]], 200)]); + Http::fake(['*' => Http::response(['stable' => ['coolify' => ['version' => '4.0.10']]], 200)]); updateCoolifyTestActionWithStatus('3|Pulling Docker images|'.now()->subMinutes(16)->toIso8601String()) ->handle(manual_update: true); diff --git a/tests/v4/Browser/UpgradeBadgeLayoutTest.php b/tests/v4/Browser/UpgradeBadgeLayoutTest.php index 361e4a097b..f90a249db2 100644 --- a/tests/v4/Browser/UpgradeBadgeLayoutTest.php +++ b/tests/v4/Browser/UpgradeBadgeLayoutTest.php @@ -8,7 +8,7 @@ uses(RefreshDatabase::class); it('keeps the update badge inside the sidebar brand area', function (string $version) { config()->set('constants.coolify.version', $version); seedBrowserResourceStack(['instanceSettings' => ['new_version_available' => true]]); - Cache::put('coolify:versions:all', ['coolify' => ['v4' => ['version' => '9.9.9']]], 3600); + Cache::put('coolify:versions', ['coolify' => ['version' => '9.9.9']], 3600); $page = visit('/login') ->fill('email', 'test@example.com')