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/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 30b188b796..c95dcc9e2f 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -73,13 +73,10 @@ 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) { @@ -103,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/scripts/install.sh b/scripts/install.sh index b6f405157c..9427c17d18 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,12 +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 '"') @@ -1131,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/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index d49d98ea24..821dacbdc9 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -19,6 +19,17 @@ it('reads the Coolify and helper versions of its channel from versions.json in i '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'); })->with(['scripts/dev-helper', 'docker-compose.dev.yml', 'docker-compose.dev-multi.yml']); diff --git a/tests/Unit/UpdateChannelTest.php b/tests/Unit/UpdateChannelTest.php index 93c1d57ff3..15c3533ce7 100644 --- a/tests/Unit/UpdateChannelTest.php +++ b/tests/Unit/UpdateChannelTest.php @@ -202,3 +202,23 @@ it('follows the channel it left after a switch to a more stable channel', functi 'stable to nightly' => ['stable', 'nightly', null], 'nightly to a custom image' => ['nightly', 'custom', null], ]); + +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(); + + $this->artisan('app:update-channel', ['channel' => $to])->assertSuccessful(); + + 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('refuses an update channel that does not exist', function () { + $this->artisan('app:update-channel', ['channel' => 'custom'])->assertFailed(); + + expect(InstanceSettings::findOrFail(0)->update_channel)->toBe('stable'); +});