mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat: add update channels to the instance settings (#12359)
This commit is contained in:
@@ -21,7 +21,6 @@
|
||||
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
|
||||
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there.
|
||||
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
|
||||
- A running dev instance mounts the checkout, and its scheduled `CheckForUpdatesJob` writes the CDN data to `base_path('versions.json')`. Check `git diff versions.json` before you commit a version change.
|
||||
- A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env.
|
||||
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
|
||||
- The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:<version>-dind` container); newer clients can reject data from older daemons.
|
||||
|
||||
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Added
|
||||
|
||||
- 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`
|
||||
- Keep the updates of the old channel after a switch to a more stable channel, until the stable release of the installed version is out
|
||||
- Publish prereleases from `next` and the release branches, tagged like `4.6.0-beta.1`, and the moving `preview` tag
|
||||
- Publish a nightly version twice a day, tagged like `4.6.0-alpha.0.20261026.1`, with the moving `nightly` tag and the nightly channel of the CDN, from `next` or from the release branch while one is in progress
|
||||
- Build `main` twice a day when it changed and move the `main` tag on ghcr.io to it, versioned like `4.5.0.20261028.1`, to test fixes before their patch release
|
||||
|
||||
+2
-1
@@ -142,7 +142,7 @@ Print what the next run publishes with `scripts/plan-version.sh --nightly`.
|
||||
|
||||
The main workflow builds `main` twice a day when it changed since the last build and moves `ghcr.io/coollabsio/coolify:main` to it, to test fixes before their patch release. A main build is versioned `<stable version>.<YYYYMMDD>.<N>`, `4.5.0.20261028.1`, which sorts after `4.5.0` and before `4.5.1`.
|
||||
|
||||
- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN
|
||||
- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN, an instance installs it through the custom channel with the tag `main`
|
||||
|
||||
Print what the next run builds with `scripts/plan-version.sh --main`.
|
||||
|
||||
@@ -172,6 +172,7 @@ The pull request workflow builds every pull request and publishes the image of a
|
||||
- `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`
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class UpdateCoolify
|
||||
|
||||
public ?string $currentVersion = null;
|
||||
|
||||
public ?string $upgradeScriptUrl = null;
|
||||
|
||||
public function handle($manual_update = false)
|
||||
{
|
||||
if (isDev()) {
|
||||
@@ -34,15 +36,24 @@ class UpdateCoolify
|
||||
return;
|
||||
}
|
||||
|
||||
if ($settings->update_channel === 'custom') {
|
||||
if ($manual_update && filled($settings->update_custom_tag)) {
|
||||
$this->latestVersion = $settings->update_custom_tag;
|
||||
$this->backupDatabase();
|
||||
$this->update(registryUrl: 'ghcr.io');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh version from CDN instead of using cache
|
||||
try {
|
||||
// A manual update runs in the web request, so it does not wait for a retry; it falls back to the cache.
|
||||
$response = cdn_http(tries: $manual_update ? 1 : 3)
|
||||
->get(config('constants.coolify.versions_url'));
|
||||
$latest = fetch_latest_versions(tries: $manual_update ? 1 : 3);
|
||||
|
||||
if ($response->successful()) {
|
||||
$versions = $response->json();
|
||||
$this->latestVersion = data_get($versions, 'coolify.v4.version');
|
||||
if ($latest !== null) {
|
||||
$this->latestVersion = data_get($latest, 'versions.coolify.v4.version');
|
||||
$this->upgradeScriptUrl = $latest['upgrade_script_url'];
|
||||
} else {
|
||||
// Fallback to cache if CDN unavailable
|
||||
$cacheVersion = get_latest_version_of_coolify();
|
||||
@@ -135,13 +146,13 @@ class UpdateCoolify
|
||||
}
|
||||
}
|
||||
|
||||
private function update()
|
||||
private function update(?string $registryUrl = null)
|
||||
{
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
|
||||
$latestHelperImageVersion = getHelperVersion();
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
$registryUrl = coolifyRegistryUrl();
|
||||
$upgradeScriptUrl = $this->upgradeScriptUrl ?? config('constants.coolify.upgrade_script_url');
|
||||
$registryUrl ??= coolifyRegistryUrl();
|
||||
|
||||
remote_process([
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
|
||||
@@ -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 function __construct(int $cdnTries = 3, public bool $channelChanged = false)
|
||||
{
|
||||
$this->cdnTries = $cdnTries;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev() || isCloud()) {
|
||||
return false;
|
||||
}
|
||||
$versions = refresh_versions_file($this->cdnTries);
|
||||
$versions = refresh_versions_file($this->cdnTries, $this->channelChanged);
|
||||
if ($versions === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -39,7 +39,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
CheckTraefikVersionJob::dispatch();
|
||||
|
||||
$latestVersion = data_get($versions, 'coolify.v4.version');
|
||||
$newVersionAvailable = version_compare($latestVersion, config('constants.coolify.version'), '>');
|
||||
$newVersionAvailable = instanceSettings()->update_channel !== 'custom'
|
||||
&& version_compare($latestVersion, config('constants.coolify.version'), '>');
|
||||
instanceSettings()->update(['new_version_available' => $newVersionAvailable]);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -31,6 +31,12 @@ class Updates extends Component
|
||||
#[Validate('required|string|in:docker.io,ghcr.io')]
|
||||
public string $docker_registry_url;
|
||||
|
||||
#[Validate('required|string|in:stable,preview,nightly,custom')]
|
||||
public string $update_channel = 'stable';
|
||||
|
||||
#[Validate('nullable|required_if:update_channel,custom|string|max:128|regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/')]
|
||||
public ?string $update_custom_tag = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
@@ -45,6 +51,8 @@ class Updates extends Component
|
||||
$this->update_check_frequency = $this->settings->update_check_frequency;
|
||||
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
|
||||
$this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io';
|
||||
$this->update_channel = $this->settings->update_channel;
|
||||
$this->update_custom_tag = $this->settings->update_custom_tag;
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
@@ -58,14 +66,26 @@ class Updates extends Component
|
||||
}
|
||||
$validated = $this->validate([
|
||||
'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'],
|
||||
'update_channel' => ['required', 'string', 'in:stable,preview,nightly,custom'],
|
||||
'update_custom_tag' => ['nullable', 'required_if:update_channel,custom', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/'],
|
||||
]);
|
||||
$this->settings->auto_update_frequency = $this->auto_update_frequency;
|
||||
$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->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();
|
||||
$this->dispatch('updateAvailable');
|
||||
}
|
||||
if ($changedFields !== []) {
|
||||
auditLog('ui.instance.settings.updated', [
|
||||
'team_id' => null,
|
||||
@@ -82,6 +102,22 @@ 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) {
|
||||
|
||||
@@ -51,6 +51,13 @@ class Upgrade extends Component
|
||||
}
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
if (data_get($settings, 'update_channel') === 'custom') {
|
||||
$this->latestVersion = (string) $settings->update_custom_tag;
|
||||
$this->isUpgradeAvailable = $this->fullButton && $this->latestVersion !== '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hasNewerVersion = version_compare($this->latestVersion, $this->currentVersion, '>');
|
||||
$newVersionAvailable = (bool) data_get($settings, 'new_version_available', false);
|
||||
|
||||
@@ -84,7 +91,7 @@ class Upgrade extends Component
|
||||
'team_id' => null,
|
||||
'resource' => 'instance',
|
||||
'from_version' => config('constants.coolify.version'),
|
||||
'to_version' => get_latest_version_of_coolify(),
|
||||
'to_version' => $this->latestVersion,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -46,6 +46,9 @@ class InstanceSettings extends Model
|
||||
'allowed_ips',
|
||||
'auto_update_frequency',
|
||||
'update_check_frequency',
|
||||
'update_channel',
|
||||
'update_custom_tag',
|
||||
'previous_update_channel',
|
||||
'new_version_available',
|
||||
'instance_timezone',
|
||||
'helper_version',
|
||||
|
||||
@@ -115,6 +115,10 @@ class CoolifyUpgradeStatus
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\d+\.\d+/', $targetVersion)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return version_compare($runningVersion, $targetVersion, '>=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log;
|
||||
function get_versions_data(): ?array
|
||||
{
|
||||
return Cache::remember('coolify:versions:all', 3600, function () {
|
||||
$versionsPath = base_path('versions.json');
|
||||
$versionsPath = File::exists(storage_path('app/versions.json')) ? storage_path('app/versions.json') : base_path('versions.json');
|
||||
|
||||
if (! File::exists($versionsPath)) {
|
||||
return null;
|
||||
@@ -89,18 +89,81 @@ function cdn_retry_delay_ms(int $attempt, Throwable $exception): int
|
||||
}
|
||||
|
||||
/**
|
||||
* 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{versions: array, 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'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'versions' => $versions,
|
||||
'upgrade_script_url' => $channelUrl ? "{$channelUrl}/upgrade.sh" : config('constants.coolify.upgrade_script_url'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, mixed> $versions The Coolify version of each channel
|
||||
*/
|
||||
function newest_update_channel(array $versions): string
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
$channels = ['stable', 'preview', 'nightly'];
|
||||
$follows = (int) array_search($settings->update_channel, $channels, true);
|
||||
$followsUntilRelease = $settings->previous_update_channel ? (int) array_search($settings->previous_update_channel, $channels, true) : -1;
|
||||
$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] ?? ''), '<=')) {
|
||||
continue;
|
||||
}
|
||||
if ($rank <= $follows || ($rank <= $followsUntilRelease && version_compare($version, $release, '<'))) {
|
||||
$newest = $channel;
|
||||
}
|
||||
}
|
||||
|
||||
return $newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @return array|null The written versions data, or null if the CDN fetch failed
|
||||
*/
|
||||
function refresh_versions_file(int $tries = 3): ?array
|
||||
function refresh_versions_file(int $tries = 3, bool $channelChanged = false): ?array
|
||||
{
|
||||
$response = cdn_http($tries)->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()]);
|
||||
$versionsPath = storage_path('app/versions.json');
|
||||
$versions = fetch_latest_versions($tries)['versions'] ?? null;
|
||||
if ($versions === 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;
|
||||
}
|
||||
@@ -109,8 +172,8 @@ function refresh_versions_file(int $tries = 3): ?array
|
||||
$currentVersion = config('constants.coolify.version');
|
||||
|
||||
$existingCoolifyVersion = null;
|
||||
if (File::exists(base_path('versions.json'))) {
|
||||
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
|
||||
if (! $channelChanged && File::exists($versionsPath)) {
|
||||
$existingVersions = json_decode(File::get($versionsPath), true);
|
||||
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
|
||||
}
|
||||
|
||||
@@ -138,7 +201,7 @@ function refresh_versions_file(int $tries = 3): ?array
|
||||
|
||||
data_set($versions, 'coolify.v4.version', $bestVersion);
|
||||
|
||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||
File::replace($versionsPath, json_encode($versions, JSON_PRETTY_PRINT));
|
||||
invalidate_versions_cache();
|
||||
update_helper_version($versions);
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->string('update_channel')->default('stable');
|
||||
$table->string('update_custom_tag')->nullable();
|
||||
$table->string('previous_update_channel')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
<x-settings.layout>
|
||||
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
|
||||
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
|
||||
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
|
||||
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency,update_channel,update_custom_tag" />
|
||||
|
||||
<x-application.settings-section title="Updates"
|
||||
helper="Install the latest Coolify version manually when an update is available.">
|
||||
@@ -18,6 +18,36 @@
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="update_channel" label="Channel" live :options="[
|
||||
['value' => 'stable', 'label' => 'Stable', 'description' => 'Stable releases'],
|
||||
['value' => 'preview', 'label' => 'Preview', 'description' => 'Betas and release candidates, then the stable release'],
|
||||
['value' => 'nightly', 'label' => 'Nightly', 'description' => 'A build of the next version twice a day, then every release'],
|
||||
['value' => 'custom', 'label' => 'Custom image', 'description' => 'A pinned image, such as the build of a pull request'],
|
||||
]"
|
||||
helper="The versions this instance upgrades to." />
|
||||
|
||||
@if ($update_channel === 'custom')
|
||||
<x-forms.input required id="update_custom_tag" label="Image tag" placeholder="pr-1234"
|
||||
helper="A tag of ghcr.io/coollabsio/coolify, such as pr-1234 for the build of pull request 1234." />
|
||||
@else
|
||||
<div class="hidden lg:block"></div>
|
||||
@endif
|
||||
|
||||
@if ($update_channel === 'nightly')
|
||||
<x-callout type="danger" title="Nightly builds can break this instance" class="lg:col-span-2">
|
||||
Nightly builds are untested builds of the next version. Things can break at any time and
|
||||
breaking changes are possible, including database changes a stable release does not expect.
|
||||
Back up first and use nightly only on an instance you can rebuild. After switching back to stable,
|
||||
the instance keeps getting the nightlies of its version until that version is released as stable.
|
||||
</x-callout>
|
||||
@elseif ($update_channel === 'custom')
|
||||
<x-callout type="danger" title="A custom image can break this instance" class="lg:col-span-2">
|
||||
The image installs only when you upgrade to it here, and automatic updates pause until you
|
||||
switch back to a channel. Pull request builds are untested and can include database changes
|
||||
a later release does not expect.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
<x-forms.input required id="update_check_frequency" label="Check frequency"
|
||||
placeholder="0 * * * *"
|
||||
helper="A cron expression or preset such as hourly, daily, weekly, monthly, or yearly." />
|
||||
|
||||
@@ -19,7 +19,7 @@ it('checks installed Traefik versions after refreshing available versions', func
|
||||
]),
|
||||
]);
|
||||
File::shouldReceive('exists')->andReturn(false);
|
||||
File::shouldReceive('put')->once();
|
||||
File::shouldReceive('replace')->once();
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ 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('put')->once()->with(base_path('versions.json'), Mockery::type('string'));
|
||||
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([
|
||||
|
||||
@@ -32,7 +32,7 @@ beforeEach(function () {
|
||||
|
||||
it('reports a failed CDN request instead of "No new version available"', function () {
|
||||
Http::fake(['*' => Http::response('error code: 1200', 503)]);
|
||||
File::partialMock()->shouldReceive('put')->with(base_path('versions.json'), Mockery::any())->never();
|
||||
File::partialMock()->shouldReceive('replace')->with(storage_path('app/versions.json'), Mockery::any())->never();
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->call('checkManually')
|
||||
@@ -42,7 +42,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('put')->with(base_path('versions.json'), Mockery::any())->once();
|
||||
File::partialMock()->shouldReceive('replace')->with(storage_path('app/versions.json'), Mockery::any())->once();
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->call('checkManually')
|
||||
|
||||
@@ -37,16 +37,16 @@ it('uses max of CDN and cache versions', function () {
|
||||
|
||||
// Cache has newer version
|
||||
File::shouldReceive('exists')
|
||||
->with(base_path('versions.json'))
|
||||
->with(storage_path('app/versions.json'))
|
||||
->andReturn(true);
|
||||
|
||||
File::shouldReceive('get')
|
||||
->with(base_path('versions.json'))
|
||||
->with(storage_path('app/versions.json'))
|
||||
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
File::shouldReceive('replace')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
->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)
|
||||
@@ -77,16 +77,16 @@ it('never downgrades from current running version', function () {
|
||||
|
||||
// Cache also has older version
|
||||
File::shouldReceive('exists')
|
||||
->with(base_path('versions.json'))
|
||||
->with(storage_path('app/versions.json'))
|
||||
->andReturn(true);
|
||||
|
||||
File::shouldReceive('get')
|
||||
->with(base_path('versions.json'))
|
||||
->with(storage_path('app/versions.json'))
|
||||
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
File::shouldReceive('replace')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
->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)
|
||||
@@ -131,9 +131,9 @@ it('preserves other component versions when preventing Coolify downgrade', funct
|
||||
'traefik' => ['v3.5' => '3.5.6'],
|
||||
]));
|
||||
|
||||
File::shouldReceive('put')
|
||||
File::shouldReceive('replace')
|
||||
->once()
|
||||
->with(base_path('versions.json'), Mockery::on(function ($json) {
|
||||
->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');
|
||||
@@ -167,7 +167,7 @@ it('preserves other component versions when preventing Coolify downgrade', funct
|
||||
|
||||
it('keeps the local versions.json when the CDN fetch fails', function () {
|
||||
Http::fake(['*' => Http::response('', 500)]);
|
||||
File::shouldReceive('put')->never();
|
||||
File::shouldReceive('replace')->never();
|
||||
Cache::shouldReceive('forget')->never();
|
||||
|
||||
expect(refresh_versions_file())->toBeNull();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Livewire\Settings\Updates;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
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;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.version' => '4.5.0',
|
||||
'constants.coolify.helper_version' => '1.0.19',
|
||||
'constants.coolify.versions_url' => 'https://cdn.example.com/coolify/versions.json',
|
||||
'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/coolify/upgrade.sh',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
Team::factory()->create(['id' => 0]);
|
||||
Server::forceCreate(['id' => 0, 'name' => 'localhost', 'ip' => '127.0.0.1', 'user' => 'root', 'team_id' => 0, 'private_key_id' => 1]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_auto_update_enabled' => true, 'auto_update_frequency' => '0 0 * * *', 'update_check_frequency' => '0 * * * *']);
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::delete(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
|
||||
{
|
||||
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(['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]]]),
|
||||
]);
|
||||
}
|
||||
|
||||
it('follows the newest version of the channel and the more stable ones', function (string $channel, string $version, string $upgradeScriptUrl) {
|
||||
updateChannelTestUse($channel);
|
||||
|
||||
expect(fetch_latest_versions())->toMatchArray([
|
||||
'versions' => ['coolify' => ['v4' => ['version' => $version]]],
|
||||
'upgrade_script_url' => $upgradeScriptUrl,
|
||||
]);
|
||||
})->with([
|
||||
'stable' => ['stable', '4.5.1', 'https://cdn.example.com/coolify/upgrade.sh'],
|
||||
'preview' => ['preview', '4.6.0-beta.2', 'https://cdn.example.com/coolify/preview/upgrade.sh'],
|
||||
'nightly while a newer beta waits for the next nightly' => ['nightly', '4.6.0-beta.2', 'https://cdn.example.com/coolify/preview/upgrade.sh'],
|
||||
]);
|
||||
|
||||
it('asks only the stable channel of the CDN on the stable channel', function () {
|
||||
updateChannelTestUse('stable');
|
||||
|
||||
fetch_latest_versions();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('skips a channel that release.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 () {
|
||||
InstanceSettings::findOrFail(0)->update(['update_channel' => 'nightly']);
|
||||
Once::flush();
|
||||
Http::fake(['cdn.example.com/coolify/release.json' => Http::response('', 404)]);
|
||||
|
||||
expect(fetch_latest_versions(tries: 1))->toBeNull();
|
||||
});
|
||||
|
||||
it('upgrades with the upgrade script of the channel of the newest version', function () {
|
||||
updateChannelTestUse('nightly');
|
||||
|
||||
(new UpdateCoolify)->handle(manual_update: true);
|
||||
|
||||
expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))->toBe(
|
||||
"curl -fsSL https://cdn.example.com/coolify/preview/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
|
||||
"bash /data/coolify/source/upgrade.sh '4.6.0-beta.2' '1.0.19' 'docker.io'"
|
||||
);
|
||||
});
|
||||
|
||||
it('upgrades to the custom image from ghcr.io only when asked', function () {
|
||||
updateChannelTestUse('custom', 'pr-1234');
|
||||
|
||||
(new UpdateCoolify)->handle();
|
||||
expect(Activity::query()->count())->toBe(0);
|
||||
|
||||
(new UpdateCoolify)->handle(manual_update: true);
|
||||
expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))->toBe(
|
||||
"curl -fsSL https://cdn.example.com/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh\n".
|
||||
"bash /data/coolify/source/upgrade.sh 'pr-1234' '1.0.19' 'ghcr.io'"
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no new version while a custom image is pinned', function () {
|
||||
updateChannelTestUse('custom', 'pr-1234');
|
||||
|
||||
(new CheckForUpdatesJob)->handle();
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('treats an image tag that names no version as reached once the upgrade completes', function () {
|
||||
expect(CoolifyUpgradeStatus::hasReachedTargetVersion('4.6.0-beta.1-pr.1234', 'pr-1234'))->toBeTrue()
|
||||
->and(CoolifyUpgradeStatus::hasReachedTargetVersion('4.5.0', '4.5.1'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts only an image tag for the custom image', function (?string $tag, bool $valid) {
|
||||
$user = User::factory()->create();
|
||||
Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
$component = Livewire::test(Updates::class)
|
||||
->set('update_channel', 'custom')
|
||||
->set('update_custom_tag', $tag)
|
||||
->call('submit');
|
||||
|
||||
$valid ? $component->assertHasNoErrors('update_custom_tag') : $component->assertHasErrors('update_custom_tag');
|
||||
})->with([
|
||||
'pull request' => ['pr-1234', true],
|
||||
'version' => ['4.6.0-beta.1', true],
|
||||
'missing' => [null, false],
|
||||
'shell' => ['pr-1; touch /tmp/pwned', false],
|
||||
'leading dash' => ['-pr', false],
|
||||
]);
|
||||
|
||||
it('warns before an instance switches to nightly or a custom image', function (string $channel, string $warning) {
|
||||
$user = User::factory()->create();
|
||||
Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->assertDontSee($warning)
|
||||
->set('update_channel', $channel)
|
||||
->assertSee($warning);
|
||||
})->with([
|
||||
'nightly' => ['nightly', 'Nightly builds can break this instance'],
|
||||
'custom image' => ['custom', 'A custom image can break this instance'],
|
||||
]);
|
||||
|
||||
it('keeps the nightlies of the running version after a switch to stable until its stable release', function (string $stable, string $nightly, string $version) {
|
||||
config(['constants.coolify.version' => '4.6.0-beta.1.20261027.3']);
|
||||
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]]]),
|
||||
]);
|
||||
|
||||
expect(data_get(fetch_latest_versions(), 'versions.coolify.v4.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'],
|
||||
'no nightly of 4.7.0 before 4.6.0 is stable' => ['4.5.1', '4.7.0-alpha.0.20261101.1', '4.5.1'],
|
||||
]);
|
||||
|
||||
it('follows the channel it left after a switch to a more stable channel', function (string $from, string $to, ?string $followed) {
|
||||
$user = User::factory()->create();
|
||||
Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
updateChannelTestUse($from);
|
||||
|
||||
$component = new class extends Updates
|
||||
{
|
||||
protected function syncRegistryUrlToEnv(string $registryUrl): void {}
|
||||
};
|
||||
$component->settings = InstanceSettings::findOrFail(0);
|
||||
$component->auto_update_frequency = '0 0 * * *';
|
||||
$component->update_check_frequency = '0 * * * *';
|
||||
$component->is_auto_update_enabled = true;
|
||||
$component->docker_registry_url = 'docker.io';
|
||||
$component->update_channel = $to;
|
||||
$component->update_custom_tag = $to === 'custom' ? 'pr-1234' : null;
|
||||
$component->instantSave();
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->previous_update_channel)->toBe($followed);
|
||||
})->with([
|
||||
'nightly to stable' => ['nightly', 'stable', 'nightly'],
|
||||
'nightly to preview' => ['nightly', 'preview', 'nightly'],
|
||||
'preview to stable' => ['preview', 'stable', 'preview'],
|
||||
'stable to nightly' => ['stable', 'nightly', null],
|
||||
'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');
|
||||
|
||||
refresh_versions_file(tries: 1, channelChanged: true);
|
||||
|
||||
expect(data_get(json_decode(File::get(storage_path('app/versions.json')), true), 'coolify.v4.version'))->toBe('4.5.1');
|
||||
});
|
||||
|
||||
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)]);
|
||||
|
||||
refresh_versions_file(tries: 1, channelChanged: true);
|
||||
|
||||
expect(File::exists(storage_path('app/versions.json')))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user