From d8c1b926ca9cc84952643ef7bd04d8d41ea34bf5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:25:38 +0100 Subject: [PATCH 01/18] docs(readme): remove OpenElements from small sponsors --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 82da094800..d2ffe7141a 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,6 @@ Thank you so much! Piloterr Alexey Panteleev SummYT - YouTube Summarizer -OpenElements Xaman Monadical Magic as a Service From 51029aa8b97ade582c8aa126145d966e56afeb91 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:58:15 +0100 Subject: [PATCH 02/18] fix(new-resource): accept a base directory without a leading slash Closes #12428 Co-Authored-By: Claude Opus 5.5 --- .../Project/New/GiteaPrivateRepository.php | 1 + .../Project/New/GithubPrivateRepository.php | 1 + .../New/GithubPrivateRepositoryDeployKey.php | 1 + .../Project/New/GitlabPrivateRepository.php | 1 + app/Livewire/Project/New/PublicGitRepository.php | 1 + app/Support/ValidationPatterns.php | 11 +++++++++++ .../new/gitea-private-repository.blade.php | 2 +- ...github-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/gitlab-private-repository.blade.php | 2 +- .../project/new/public-git-repository.blade.php | 2 +- ...loyKeyApplicationCreationAuthorizationTest.php | 15 +++++++++++++++ 12 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 9c1fb558c8..bcaba739f6 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -165,6 +165,7 @@ class GiteaPrivateRepository extends Component { try { $this->authorize('create', Application::class); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index dacfbe4881..e3dd5219dd 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -202,6 +202,7 @@ class GithubPrivateRepository extends Component { try { $this->authorize('create', Application::class); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); // Validate git repository parts and branch $validator = validator([ diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 2a74efc50b..200dd623b4 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -155,6 +155,7 @@ class GithubPrivateRepositoryDeployKey extends Component $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $this->validate(); try { $destination_uuid = $this->query['destination'] ?? null; diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index a11fc69c01..9871c3af4c 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -162,6 +162,7 @@ class GitlabPrivateRepository extends Component { try { $this->authorize('create', Application::class); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3ea109b4a0..349b431e43 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -278,6 +278,7 @@ class PublicGitRepository extends Component { try { $this->authorize('create', Application::class); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $this->validate(); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 77c89f2fbe..1aa46ddabd 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -576,6 +576,17 @@ class ValidationPatterns return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN]; } + /** + * Normalize a typed directory path to the form that DIRECTORY_PATH_PATTERN expects: + * "apps/web/" becomes "/apps/web", and an empty value becomes "/". + */ + public static function normalizeDirectoryPath(?string $path): string + { + $path = trim(trim((string) $path), '/'); + + return '/'.$path; + } + /** * Get validation rules for Docker build target fields */ diff --git a/resources/views/livewire/project/new/gitea-private-repository.blade.php b/resources/views/livewire/project/new/gitea-private-repository.blade.php index 6ed15d6312..da18da3cf7 100644 --- a/resources/views/livewire/project/new/gitea-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitea-private-repository.blade.php @@ -135,7 +135,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 6ee5dfe990..313cc5c919 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -85,7 +85,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index c89472dd7a..431a124175 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -162,7 +162,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php index 0a0e7e152d..064c28d5a6 100644 --- a/resources/views/livewire/project/new/gitlab-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -139,7 +139,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 663e267272..39c186636e 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -111,7 +111,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php index 4dc81bc4b4..eb86f1a293 100644 --- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -128,6 +128,21 @@ test('deploy key application keeps the base directory for every build pack', fun expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); })->with(['dockerfile', 'railpack', 'static', 'dockercompose']); +test('deploy key application accepts a base directory without a leading slash', function (string $typed) { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/monorepo.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', $typed) + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with(['apps/api', 'apps/api/', ' /apps/api/ ']); + test('deploy key application uses the repository root when the base directory is empty', function () { actAsDeployKeyUser($this->admin, $this->team); From f2f89ccfae497aa9e9ee9256fd1ee71b6bbd23b5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:13 +0100 Subject: [PATCH 03/18] fix(canvas): add SQLite to database engines Closes #12410 Co-Authored-By: Claude Opus 5.5 --- resources/js/deploy-canvas.js | 2 +- resources/js/deploy-canvas.test.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js index 82d28ccd52..318e09f6a7 100644 --- a/resources/js/deploy-canvas.js +++ b/resources/js/deploy-canvas.js @@ -104,7 +104,7 @@ const POSTGRES_IMAGES = [ ['pgvector/pgvector:pg18', 'PGVector 18'], ['pgvector/pgvector:pg17', 'PGVector 17'], ]; -const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse'], ['sqlite', 'SQLite']]; // One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). let serviceTemplates = null; const templateOptions = () => { diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js index a8fbecc2d7..1673ce4e33 100644 --- a/resources/js/deploy-canvas.test.js +++ b/resources/js/deploy-canvas.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { freeSpot, zoomShortcut } from './canvas-core.js'; -import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; +import { TYPES, canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; test('splits the tag out of an image name', () => { assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); @@ -167,6 +167,7 @@ test('shows the logo of what a source step creates, like the rest of Coolify', ( assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('database', { engine: 'sqlite' }), '/svgs/resources/sqlite.svg'); assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); @@ -187,3 +188,8 @@ test('reads the browser zoom shortcut so the canvas can zoom instead', () => { assert.equal(key('-', { ctrlKey: true, altKey: true }), null); assert.equal(key('s', { metaKey: true }), null); }); + +test('offers every database engine of the New resource page', () => { + const engines = TYPES.database.fields.find(f => f.key === 'engine').options.map(([value]) => value); + assert.deepEqual(engines, ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', 'sqlite']); +}); From 31653c27d1d4f4e62b96e97d8106e38a0d2194c5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:22 +0100 Subject: [PATCH 04/18] fix(clone): add Compose labels to cloned volumes Closes #12429 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/VolumeCloneJob.php | 31 ++++++++++++++++++++++++++++ tests/Feature/VolumeCloneJobTest.php | 31 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 953afc4eb1..4887619509 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -4,6 +4,8 @@ namespace App\Jobs; use App\Models\LocalPersistentVolume; use App\Models\Server; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Traits\StagesCloneArchives; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -202,11 +204,40 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue foreach ($configuration['options'] as $key => $value) { $command .= ' --opt '.escapeshellarg("{$key}={$value}"); } + foreach ($this->composeVolumeLabels() as $key => $value) { + $command .= ' --label '.escapeshellarg("{$key}={$value}"); + } $command .= ' '.escapeshellarg($this->targetVolume); return 'sh -c '.escapeshellarg($command); } + /** + * The labels that Docker Compose puts on a volume it creates. Without the project label, every + * `docker compose up` of the resource warns that the volume "was not created by Docker Compose". + * The project is the `--project-name` of the resource: the service UUID for a service, else the + * resource UUID (a standalone database uses its configuration directory, which is its UUID). + * The volume key in the generated Compose file is the volume name. + * + * @return array + */ + protected function composeVolumeLabels(): array + { + $resource = $this->persistentVolume->resource; + $project = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase + ? $resource->service?->uuid + : $resource?->uuid; + + if (blank($project)) { + return []; + } + + return [ + 'com.docker.compose.project' => (string) $project, + 'com.docker.compose.volume' => $this->targetVolume, + ]; + } + /** * A `local` volume with `type: none` and `o: bind` is a host folder. Copying the data would write * into that folder (on the same server, the folder of the source volume itself), so the clone gets diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php index b0f6e627d4..9df9cb6e85 100644 --- a/tests/Feature/VolumeCloneJobTest.php +++ b/tests/Feature/VolumeCloneJobTest.php @@ -6,6 +6,8 @@ use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; @@ -192,3 +194,32 @@ test('a non-root server runs the volume create as one sudo shell script', functi expect(volumeCloneCommands())->toContain('sudo sh -c '.escapeshellarg($inner)) ->and(volumeCloneCommands())->toContain('sudo docker volume inspect'); }); + +test('a service clone creates the target volume with the Compose labels of the service project', function () { + $service = Service::factory()->create(); + $serviceApplication = ServiceApplication::create(['name' => 'gitea', 'service_id' => $service->id]); + $this->volume->update(['resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); + fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'local', 'Options' => null]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$service->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +}); + +test('an application clone creates the target volume with the Compose labels of the application project', function () { + $application = Application::factory()->create(); + $this->volume->update(['resource_type' => Application::class, 'resource_id' => $application->id]); + fakeVolumeCloneServer([ + 'Name' => 'clone-source', + 'Driver' => 'local', + 'Options' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/app-data'], + ]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$application->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +}); From 33a6682791e4fae5af0c6fe29f3a76ee695f7a0b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:25 +0100 Subject: [PATCH 05/18] fix(backups): keep non-deployment helper containers during cleanup Closes #12431 --- app/Jobs/CleanupHelperContainersJob.php | 68 ++++++++----------- .../CleanupHelperContainersJobDedupTest.php | 39 +++++++++++ 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 368bad5eaf..2c44b57dde 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -37,56 +37,42 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; } + /** + * Removes helper containers of finished deployments. Backups, imports, and file browsers also run in + * helper containers, so a container is removed only when its name is the UUID of a deployment that + * is not queued or in progress. + */ public function handle(): void { try { - // Get all active deployments on this server - $activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id) - ->whereIn('status', [ + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); + $helperContainers = collect(json_decode((string) $containers, true) ?: []) + ->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names'))); + + if ($helperContainers->isEmpty()) { + return; + } + + $finishedDeploymentUuids = ApplicationDeploymentQueue::query() + ->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all()) + ->whereNotIn('status', [ ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value, ]) ->pluck('deployment_uuid') - ->toArray(); + ->all(); - \Log::info('CleanupHelperContainersJob - Active deployments', [ - 'server' => $this->server->name, - 'active_deployment_uuids' => $activeDeployments, - ]); - - $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); - $helperContainers = collect(json_decode($containers)); - - if ($helperContainers->count() > 0) { - foreach ($helperContainers as $container) { - $containerId = data_get($container, 'ID'); - $containerName = data_get($container, 'Names'); - - // Check if this container belongs to an active deployment - $isActiveDeployment = false; - foreach ($activeDeployments as $deploymentUuid) { - if (str_contains($containerName, $deploymentUuid)) { - $isActiveDeployment = true; - break; - } - } - - if ($isActiveDeployment) { - \Log::info('CleanupHelperContainersJob - Skipping active deployment container', [ - 'container' => $containerName, - 'id' => $containerId, - ]); - - continue; - } - - \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [ - 'container' => $containerName, - 'id' => $containerId, - ]); - - instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false); + foreach ($helperContainers as $container) { + if (! in_array($container['Names'], $finishedDeploymentUuids, true)) { + continue; } + + \Log::info('CleanupHelperContainersJob - Removing orphaned deployment helper container', [ + 'container' => $container['Names'], + 'id' => $container['ID'], + ]); + + instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false); } } catch (\Throwable $e) { send_internal_notification('CleanupHelperContainersJob failed with error: '.$e->getMessage()); diff --git a/tests/Feature/CleanupHelperContainersJobDedupTest.php b/tests/Feature/CleanupHelperContainersJobDedupTest.php index 9fd52d4404..7a6cbde9cc 100644 --- a/tests/Feature/CleanupHelperContainersJobDedupTest.php +++ b/tests/Feature/CleanupHelperContainersJobDedupTest.php @@ -1,10 +1,16 @@ false]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + foreach (['finished-uuid' => ApplicationDeploymentStatus::FINISHED, 'failed-uuid' => ApplicationDeploymentStatus::FAILED, 'running-uuid' => ApplicationDeploymentStatus::IN_PROGRESS] as $uuid => $status) { + ApplicationDeploymentQueue::create(['application_id' => '1', 'deployment_uuid' => $uuid, 'server_id' => $server->id, 'status' => $status->value]); + } + $containers = collect([ + 'finished-uuid' => 'aaa111', + 'failed-uuid' => 'bbb222', + 'running-uuid' => 'ccc333', + 'volume-upload-abc' => 'ddd444', + 'volume-backup-abc' => 'eee555', + 'backup-of-abc' => 'fff666', + ])->map(fn (string $id, string $name): array => ['ID' => $id, 'Names' => $name, 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'])->values(); + + $removed = []; + Process::fake(function (PendingProcess $process) use ($containers, &$removed) { + $script = remoteProcessScript($process); + if (preg_match("/docker container rm -f '?([a-z0-9]+)/", $script, $match) === 1) { + $removed[] = $match[1]; + } + + return Process::result(str_contains($script, 'docker container ps') ? $containers->toJson() : ''); + }); + + (new CleanupHelperContainersJob($server))->handle(); + + expect($removed)->toBe(['aaa111', 'bbb222']); +}); From dbd211486bfbe44a85fcaeecca85fe602b96e55c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:53 +0100 Subject: [PATCH 06/18] fix(backups): let service databases choose the databases to back up Closes #12432 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/DatabaseBackupJob.php | 45 ++++++++----------- .../database/backup-edit/general.blade.php | 13 +++++- tests/Feature/BackupEditValidationTest.php | 36 +++++++++++++++ tests/Feature/BackupRunSafetyTest.php | 29 ++++++++++++ 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 2fe350e37d..02094a9dc2 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -169,6 +169,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // The container name is validated above, so it is a safe folder name. Keep the folder of earlier releases. $this->directory_name = $serviceName.'-'.$this->container_name; $escapedContainerName = escapeshellarg($this->container_name); + $configuredDatabases = $this->configuredDatabasesToBackup(); if (str($databaseType)->contains('postgres')) { $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); @@ -180,7 +181,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('POSTGRES_DB='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('POSTGRES_DB=')->value(); } else { $databasesToBackup = $this->database->postgres_user; @@ -207,15 +210,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('MYSQL_DATABASE='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MYSQL_DATABASE'); + $this->recordUndeterminedDatabases('MYSQL_DATABASE'); - return; - } + return; } } elseif (str($databaseType)->contains('mariadb')) { $commands[] = "docker exec {$escapedContainerName} env"; @@ -235,27 +237,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MARIADB_DATABASE='); - })->first(); + $db = $envs->first(fn ($env) => str($env)->startsWith('MARIADB_DATABASE=')) + ?? $envs->first(fn ($env) => str($env)->startsWith('MYSQL_DATABASE=')); - if ($db) { - $databasesToBackup = str($db)->after('MARIADB_DATABASE=')->value(); + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { + $databasesToBackup = str($db)->after('=')->value(); } else { - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MYSQL_DATABASE='); - })->first(); + $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - if ($db) { - $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); - } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - - return; - } - } + return; } } elseif (str($databaseType)->contains('mongo')) { $databasesToBackup = ['*']; @@ -1104,7 +1096,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } /** - * Databases picked on the backup, for service databases whose container does not name one. + * Databases picked in the backup settings of a service database. They take priority over the + * database that the container env names, as for standalone databases. * * @return string|array|null */ diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 56cf82e507..f5a0c1d738 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -24,9 +24,14 @@
+ @php + $isServiceSqlDatabase = $backup->database instanceof \App\Models\ServiceDatabase + && str($backup->database->databaseType())->contains(['postgres', 'mysql', 'mariadb']); + @endphp @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0 || $backup->database_type === 'App\Models\StandaloneMysql' - || $backup->database_type === 'App\Models\StandaloneMariadb') + || $backup->database_type === 'App\Models\StandaloneMariadb' + || $isServiceSqlDatabase)

- Add one or more database names. Leave empty to include the default database. + @if ($isServiceSqlDatabase) + Add one or more database names. Leave empty to back up the database that the container env names. + @else + Add one or more database names. Leave empty to include the default database. + @endif

@endif diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 93d38c78e5..a052562811 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -678,3 +678,39 @@ it('toggles the schedule from the form and notifies the page header', function ( expect((bool) $backup->fresh()->enabled)->toBeFalse(); }); + +it('lets a service database backup choose the databases to back up', function (string $image, bool $showsSelection) { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => $image]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $this->team->id, + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect()]); + + $showsSelection + ? $component->assertSee('Database selection')->assertSee('Databases to back up') + : $component->assertDontSee('Database selection'); + + if ($showsSelection) { + $component->set('databasesToBackup', 'shop')->call('submit')->assertHasNoErrors(); + expect($backup->fresh()->databases_to_backup)->toBe('shop'); + } +})->with([ + 'mariadb' => ['mariadb:11', true], + 'mysql' => ['mysql:8', true], + 'postgres' => ['postgres:16-alpine', true], + 'mongodb' => ['mongo:7', false], +]); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index b058cbadab..a349afb277 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -361,6 +361,35 @@ it('backs up a service database without a database env variable from the backup 'mysql, selected database' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'], ]); +it('backs up the databases from the backup settings instead of the container env database', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team, ['databases_to_backup' => 'shop,blog']); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('database_name')->all())->toBe(['shop', 'blog']); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + +it('backs up the container env database when the backup settings name none', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->sole()->database_name)->toBe('app'); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mariadb with mysql env' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + it('records a failed execution and notifies when no service database to back up is known', function (string $image, string $env) { $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); $backup = backupRunSafetySchedule($database, $this->team); From b9ad48e1042e00279b386b8979b5d6510480d2fb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:02:19 +0100 Subject: [PATCH 07/18] fix(deployments): pass port-suffixed compose service URL variables Docker Compose applications dropped the stored SERVICE_URL__ and SERVICE_FQDN__ variables and only generated the forms without a port, so compose references to them were blank. Generate the port-suffixed forms from the current domain for runtime and build-time, production and preview deployments. Closes #12427 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 97 ++++++++----------- ...ationDeploymentControlVarFilteringTest.php | 57 +++++++++++ 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 404d3a9ad3..3944b6c0dc 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1938,22 +1938,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); - - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - // Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid. - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services from processed compose @@ -2015,21 +2001,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); - - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services @@ -2112,6 +2085,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + /** + * SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables + * that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended. + * + * @return array + */ + private function composeServiceUrlVariables(?string $domainsJson): array + { + $storedKeys = $this->application->environment_variables()->pluck('key'); + $variables = []; + foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) { + $parsedDomain = composeDomainEntryString($domain); + if (blank($parsedDomain)) { + continue; + } + $url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null); + $values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()]; + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value(); + foreach ($values as $type => $value) { + $variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value; + } + $pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/'; + foreach ($storedKeys as $key) { + if (preg_match($pattern, $key, $matches)) { + $variables[$key] = "{$values[$matches[1]]}:{$matches[2]}"; + } + } + } + + return $variables; + } + private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { $key = str($environmentVariable->key); @@ -2333,19 +2338,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments - $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } else { // Generate SERVICE_NAME for preview deployments @@ -2356,19 +2350,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } } diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 3d6707505f..a4527e59d6 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -2143,3 +2143,60 @@ YAML; 'production' => 0, 'preview' => 42, ]); + +it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) { + $compose = <<<'YAML' +services: + docmost: + build: . + environment: + - SERVICE_URL_DOCMOST_3000 + - APP_URL=$SERVICE_URL_DOCMOST_3000 +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]), + ]); + + foreach ([ + 'SERVICE_URL_DOCMOST' => 'https://docs.example.com', + 'SERVICE_FQDN_DOCMOST' => 'docs.example.com', + 'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000', + 'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000', + ] as $key => $value) { + createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]); + } + + $overrides = ['pull_request_id' => $pullRequestId]; + $expectedHost = 'docs.example.com'; + if ($pullRequestId !== 0) { + $expectedHost = '7.docs.example.com'; + $overrides['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/7', + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]), + ]); + } + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides); + + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + expect($runtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}") + ->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000") + ->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000") + ->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com'); + + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n"); + expect($buildtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'") + ->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'") + ->not->toContain('stale.example.com'); +})->with([ + 'production' => [0], + 'preview' => [7], +]); From 972ab7f6f45b5cc6c2efdff207738a4e23d23756 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:22:47 +0100 Subject: [PATCH 08/18] fix(deployments): keep build secret values out of Compose runtime Unset the helper's build secret variables before Compose pulls or starts services, so containers get the runtime .env values. Give build secrets the raw stored value instead of the quoted .env form. Closes #12409 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 78 +++++++++++++------ bootstrap/helpers/docker.php | 7 +- ...ationDeploymentControlVarFilteringTest.php | 53 +++++++++++++ .../MultilineEnvironmentVariableTest.php | 6 +- 4 files changed, 113 insertions(+), 31 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3944b6c0dc..38209ab97b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -231,6 +231,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection|string $build_secrets; + /** + * Build-time variables that the helper container has in its environment for build secrets. + * + * @var array + */ + private array $helperBuildSecretEnvironmentKeys = []; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -1086,7 +1093,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $command = serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; } else { $workdir = $this->workdir; - $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"); + $command = executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}")); } $this->execute_remote_command([ @@ -1137,7 +1144,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->workdir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } catch (\RuntimeException $e) { if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { @@ -1182,7 +1189,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } else { $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->basedir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } } else { @@ -1201,7 +1208,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment($command)), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); $this->write_deployment_configurations(); } @@ -5460,6 +5467,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function generate_docker_env_flags_for_secrets() { + $this->helperBuildSecretEnvironmentKeys = []; + // Only generate env flags if build secrets are enabled and the deployment builds an image if (! $this->application->settings->use_build_secrets || ! $this->deploymentPassesBuildtimeVariables()) { return ''; @@ -5483,26 +5492,51 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return ''; } - // Get database env vars to check for multiline flag - $env_vars = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); - - // Map to simple array format for the helper function - $vars_array = $variables->map(function ($value, $key) use ($env_vars) { - $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'); - $env = $env_vars->firstWhere('key', $key); - - return [ - 'key' => $key, - 'value' => $value, - 'is_multiline' => $env ? $env->is_multiline : false, - ]; - }); + $vars_array = $this->withRawBuildSecretValues($variables)->map(fn ($value, $key): array => [ + 'key' => $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'), + 'value' => $value, + ]); + $this->helperBuildSecretEnvironmentKeys = $vars_array->keys()->map(fn ($key): string => (string) $key)->values()->all(); return generateDockerEnvFlags($vars_array); } + /** + * The build-time values are escaped or quoted for .env files and shell build arguments, where the + * shell removes the escapes again. A build secret gets its value from the helper container as it + * is, so it gets the raw value of each variable instead. + * + * @param Collection $variables + * @return Collection + */ + private function withRawBuildSecretValues(Collection $variables): Collection + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues()); + $rawValues = $environmentVariables + ->where('is_buildtime', true) + ->whereIn('key', $variables->keys()->all()) + ->get() + ->mapWithKeys(fn (EnvironmentVariable $env): array => [$env->key => $this->resolve_environment_variable_raw($env)]); + + return $variables->map(fn ($value, $key) => $rawValues->get($key, $value)); + } + + /** + * Docker Compose prefers the process environment over `--env-file`. A command in the helper container + * that pulls or starts services must not see the build-time values that the container holds for build + * secrets, so the services get the values of the runtime .env file. + */ + private function withoutHelperBuildSecretEnvironment(string $command): string + { + if ($this->helperBuildSecretEnvironmentKeys === []) { + return $command; + } + + return 'unset '.implode(' ', $this->helperBuildSecretEnvironmentKeys)."; {$command}"; + } + private function generate_build_secrets(Collection $variables) { if ($variables->isEmpty()) { @@ -5520,7 +5554,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->implode(' '); // Docker ignores secret values for the build cache, so the hash goes in as a build arg - $this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($variables)); + $this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($this->withRawBuildSecretValues($variables))); } private function generate_secrets_hash($variables) @@ -6051,7 +6085,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); 'environment' => $key, ]; } - $secretsHash = $this->generate_secrets_hash($variables); + $secretsHash = $this->generate_secrets_hash($this->withRawBuildSecretValues($variables)); $services = data_get($composeFile, 'services', []); foreach ($services as $serviceName => &$service) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 4bdf9ca2d6..62be1532ad 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -2124,7 +2124,7 @@ function generateDockerBuildArgs($variables): Collection /** * Generate Docker environment flags from environment variables collection * - * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @param Collection|array $variables Collection of variables with 'key' and 'value' * @return string Space-separated environment flags */ function generateDockerEnvFlags($variables): string @@ -2140,11 +2140,6 @@ function generateDockerEnvFlags($variables): string throw new InvalidArgumentException('Invalid environment variable key.'); } - $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); - if ($isMultiline) { - $value = trim($value, "'"); - } - return '-e '.escapeshellarg("{$key}={$value}"); }) ->implode(' '); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index a4527e59d6..fc3fdcf363 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -2200,3 +2200,56 @@ YAML; 'production' => [0], 'preview' => [7], ]); + +it('hides the build secret values of the helper container from commands that start Compose services', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); + $application->settings()->update(['use_build_secrets' => true]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'DATABASE_URL', + 'value' => 'postgresql://user:pass@db:5432/app', + 'is_literal' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']); + $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'); + expect($envFlags)->toBe("-e 'DATABASE_URL=postgresql://user:pass@db:5432/app'"); + + // The helper container has the variable in its environment, as `docker run -e` gives it. + $command = invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'echo "[${DATABASE_URL-unset}]"'); + $process = new Process(['bash', '-c', $command], env: ['DATABASE_URL' => "'postgresql://user:pass@db:5432/app'"]); + $process->mustRun(); + + expect(trim($process->getOutput()))->toBe('[unset]'); +}); + +it('leaves commands unchanged when the helper container has no build secret values', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); + createApplicationEnvironmentVariable($application, ['key' => 'DATABASE_URL', 'value' => 'value']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']); + expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe(''); + + expect(invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'docker compose up -d')) + ->toBe('docker compose up -d'); +}); + +it('gives build secrets the stored value without the quotes that Coolify adds', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']); + $application->settings()->update(['use_build_secrets' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'LITERAL', 'value' => "'quoted' value", 'is_literal' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'MULTILINE', 'value' => "line1\nline2", 'is_multiline' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'NORMAL', 'value' => "'kept'"]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockerfile']); + $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'); + + // Read the values as the helper container gets them from `docker run -e`. + $process = new Process(['bash', '-c', 'printf "%s\n" '.$envFlags.' | sed -n "s/^\\(LITERAL\\|MULTILINE\\|NORMAL\\)=/&/p"']); + $process->mustRun(); + $output = $process->getOutput(); + + expect($output) + ->toContain("LITERAL='quoted' value\n") + ->toContain("MULTILINE=line1\n") + ->toContain("NORMAL='kept'\n"); +}); diff --git a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php index f71811b24a..4f9fed3fe7 100644 --- a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php +++ b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php @@ -16,7 +16,7 @@ test('generateDockerBuildArgs returns only keys without values', function () { test('generateDockerBuildArgs works with collection of objects', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], - (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"], ]); $buildArgs = generateDockerBuildArgs($variables); @@ -59,7 +59,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function test('generateDockerEnvFlags produces correct format', function () { $variables = [ ['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false], - ['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true], + ['key' => 'MULTILINE_VAR', 'value' => "line1\nline2"], ]; $envFlags = generateDockerEnvFlags($variables); @@ -73,7 +73,7 @@ test('generateDockerEnvFlags produces correct format', function () { test('generateDockerEnvFlags works with collection input', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], - (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"], ]); $envFlags = generateDockerEnvFlags($variables); From be7a9975410ba2dff0d267b028d461e0e0263e21 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:31:26 +0100 Subject: [PATCH 09/18] fix(metrics): stop unreachable servers from blocking web workers Co-Authored-By: Claude Opus 5.5 --- app/Services/SentinelMetricsClient.php | 71 +++++++++++++++++++-- app/Traits/HasMetrics.php | 36 +++++++---- tests/Feature/ServerMetricsPrefetchTest.php | 14 ++-- 3 files changed, 93 insertions(+), 28 deletions(-) diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index fe3d785c63..ba1f556704 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -11,6 +11,64 @@ class SentinelMetricsClient public function __construct(protected Server $server) {} + /** + * Optional charts must not occupy web workers for the general SSH timeout. + * One in-flight read per server, a short shared response cache, and a cooldown + * keep repeated dashboard refreshes from reconnecting to an unavailable server. + */ + public static function fetchOverSsh(Server $server, array $commands, string $cacheKey): ?string + { + $prefix = 'sentinel:metrics-ssh:'.$server->id; + $responseKey = $prefix.':response:'.hash('sha256', $cacheKey); + if (Cache::has($prefix.':unavailable')) { + return null; + } + $cached = Cache::get($responseKey); + if (is_string($cached)) { + return $cached; + } + + $lock = Cache::lock($prefix.':inflight', 15); + if (! $lock->get()) { + return null; + } + + try { + // Another node may have completed a fetch while this lock was acquired. + if (Cache::has($prefix.':unavailable')) { + return null; + } + $cached = Cache::get($responseKey); + if (is_string($cached)) { + return $cached; + } + + try { + $response = instant_remote_process( + $commands, + $server, + timeout: 8, + // Mux setup and health checks have separate, longer timeouts. + disableMultiplexing: true, + maxAttempts: 1 + ); + if ($response === null || trim($response) === '') { + throw new \RuntimeException('Metrics returned an empty response.'); + } + } catch (\Throwable) { + Cache::put($prefix.':unavailable', true, 60); + + return null; + } + + Cache::put($responseKey, $response, 10); + + return $response; + } finally { + $lock->release(); + } + } + /** Rounded to the minute so the history URL (and its cache key) is stable within a minute. */ public static function rangeFrom(string $range): string { @@ -238,14 +296,13 @@ class SentinelMetricsClient { $token = $this->server->settings->ensureValidSentinelToken(); - return instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -s --max-time 10 -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $command = "curl -sS --connect-timeout 2 --max-time 3 -H @- \"{$url}\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH"; + + return self::fetchOverSsh( $this->server, - false, - timeout: 15, - // Metrics are read again on the next refresh. SSH retries would keep a worker past its timeout. - maxAttempts: 1 - ); + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$command}\nCOOLIFY_SENTINEL_SCRIPT"], + $url + ) ?? ''; } private function guard(string $response): string diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 3a1648fc3f..1f74df8618 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -12,14 +12,14 @@ trait HasMetrics /** * Raw Sentinel responses from prefetchMetrics(), keyed by "type:mins"; each is read once. * - * @var array + * @var array */ protected array $prefetchedMetricResponses = []; /** * Fetch several metric types in one SSH round-trip instead of one per type. The - * get*Metrics() getters then read these responses. On a failure nothing is stored, - * so each getter fetches on its own as before. + * get*Metrics() getters then read these responses. Failed prefetches are remembered + * for this request so the getters do not retry the same unavailable server. * * @param array $types */ @@ -30,23 +30,26 @@ trait HasMetrics return; } + foreach ($types as $type) { + $this->prefetchedMetricResponses["{$type}:{$mins}"] = null; + } + $from = now()->subMinutes($mins)->toIso8601ZuluString(); $token = $this->metricsToken($server); // The token reaches curl on stdin (`-H @-`), so it is not a process argument. The token // only has letters, digits and `._-+/=` (ServerSetting::isValidSentinelToken), so it // cannot end the heredoc. Responses are separated by a 0x1E record separator. $script = implode("\nprintf '\\036'\n", array_map( - fn (string $type) => 'curl -s -H @- "'.$this->getMetricsEndpoint($type, $from, $mins)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", + fn (string $type) => 'curl -sS --connect-timeout 2 --max-time 3 -H @- "'.$this->getMetricsEndpoint($type, $from, $mins)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", $types )); - try { - $output = instant_remote_process( - ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$script}\nCOOLIFY_SENTINEL_SCRIPT"], - $server, - maxAttempts: 1 - ); - } catch (\Throwable) { + $output = SentinelMetricsClient::fetchOverSsh( + $server, + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$script}\nCOOLIFY_SENTINEL_SCRIPT"], + static::class.':'.$this->uuid.':'.implode(',', $types).':'.$mins + ); + if ($output === null) { return; } @@ -152,13 +155,18 @@ trait HasMetrics $endpoint = $this->getMetricsEndpoint($type, $from, $mins); $token = $this->metricsToken($server); - $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$endpoint}\"'"], + $command = "curl -sS --connect-timeout 2 --max-time 3 -H @- \"{$endpoint}\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH"; + $response = SentinelMetricsClient::fetchOverSsh( $server, - false + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$command}\nCOOLIFY_SENTINEL_SCRIPT"], + static::class.':'.$this->uuid.':'.$type.':'.$mins ); } + if ($response === null) { + return null; + } + if (str($response)->contains('error')) { $error = json_decode($response, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); diff --git a/tests/Feature/ServerMetricsPrefetchTest.php b/tests/Feature/ServerMetricsPrefetchTest.php index 12a5cb6078..6d84e68b35 100644 --- a/tests/Feature/ServerMetricsPrefetchTest.php +++ b/tests/Feature/ServerMetricsPrefetchTest.php @@ -52,20 +52,20 @@ it('keeps the Sentinel error of one prefetched metric', function () { ->and(fn () => $this->server->getMemoryMetrics(5))->toThrow(Exception::class, 'Unauthorized'); }); -it('falls back to one fetch per metric when the batch fails', function () { +it('does not retry an unavailable server after the batch fails', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { $commands[] = remoteProcessScript($process); - return count($commands) === 1 - ? Process::result('', 'ssh: connect to host failed', 1) - : Process::result('[{"time":2000,"percent":5}]'); + return Process::result('', 'ssh: connect to host failed', 1); }); $this->server->prefetchMetrics(['cpu', 'memory'], 5); - expect($this->server->getCpuMetrics(5))->toBe([[2000, 5.0]]) - ->and($commands)->toHaveCount(2); + expect($this->server->getCpuMetrics(5))->toBeNull() + ->and($this->server->getMemoryMetrics(5))->toBeNull() + ->and($this->server->getLoadMetrics(5))->toBeNull() + ->and($commands)->toHaveCount(1); }); it('quotes the bucketed history URL in a single fetch for a non-root user', function () { @@ -96,7 +96,7 @@ it('runs the batched metrics script with sudo for a non-root user', function () expect($commands)->toHaveCount(1) ->and($commands[0])->toContain('sudo docker exec -i coolify-sentinel sh -s') - ->and($commands[0])->toContain("\ncurl -s -H @- ") + ->and($commands[0])->toContain("\ncurl -sS --connect-timeout 2 --max-time 3 -H @- ") ->and($commands[0])->not->toContain('sudo curl') ->and($commands[0])->not->toContain('sudo printf'); }); From 0b3ca794644f6542f81d78038354c3157b753a9a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:33:15 +0100 Subject: [PATCH 10/18] fix(scheduled-tasks): show disabled state in task and backup lists Closes #12399 Co-Authored-By: Claude Opus 5.5 --- .../livewire/project/database/scheduled-backups.blade.php | 5 ++++- .../livewire/project/shared/scheduled-task/all.blade.php | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index aba61ec48c..b6c0aa5cba 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -86,10 +86,13 @@ || @js(strtolower($backup->frequency)).includes(search.toLowerCase()) || @js(strtolower($s3StorageNames)).includes(search.toLowerCase())" class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]"> -
+
{{ $backup->frequency }} + @unless ($backup->enabled) + + @endunless
diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php index 74b962edb6..54b0e5b4a2 100644 --- a/resources/views/livewire/project/shared/scheduled-task/all.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php @@ -52,6 +52,9 @@

{{ $task->name }}

+ @unless ($task->enabled) + + @endunless @if ($task->container) {{ $task->container }} From 6c650dd12734c4a2434b2cacda63b9809f937ab4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:33:29 +0100 Subject: [PATCH 11/18] fix(api): document request bodies for shared env create and update endpoints Closes #12395 Co-Authored-By: Claude Opus 5.5 --- .../SharedEnvironmentVariablesController.php | 48 +++-- openapi.json | 168 ++++++++++++++---- openapi.yaml | 105 +++++++++-- .../SharedEnvironmentVariablesOpenApiTest.php | 37 ++++ 4 files changed, 296 insertions(+), 62 deletions(-) create mode 100644 tests/Unit/SharedEnvironmentVariablesOpenApiTest.php diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index 32145f4ec3..61289e3c0a 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -12,6 +12,32 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'SharedEnvironmentVariableCreateRequest', + type: 'object', + required: ['key'], + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + new OA\Property(property: 'is_literal', type: 'boolean'), + new OA\Property(property: 'is_multiline', type: 'boolean'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), + ], +)] +#[OA\Schema( + schema: 'SharedEnvironmentVariableUpdateRequest', + description: 'Send at least one field.', + type: 'object', + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + new OA\Property(property: 'is_literal', type: 'boolean'), + new OA\Property(property: 'is_multiline', type: 'boolean'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), + ], +)] class SharedEnvironmentVariablesController extends Controller { private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']; @@ -347,20 +373,7 @@ class SharedEnvironmentVariablesController extends Controller operationId: 'create-team-shared-env', security: [['bearerAuth' => []]], tags: ['Shared Environment Variables'], - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - required: ['key'], - properties: [ - new OA\Property(property: 'key', type: 'string'), - new OA\Property(property: 'value', type: 'string', nullable: true), - new OA\Property(property: 'is_literal', type: 'boolean'), - new OA\Property(property: 'is_multiline', type: 'boolean'), - new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), - new OA\Property(property: 'comment', type: 'string', nullable: true), - ], - ), - ), + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -388,6 +401,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -476,6 +490,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -512,6 +527,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -633,6 +649,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -675,6 +692,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -800,6 +818,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -836,6 +855,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), diff --git a/openapi.json b/openapi.json index 53f3325211..e77c49af10 100644 --- a/openapi.json +++ b/openapi.json @@ -22230,37 +22230,7 @@ "content": { "application\/json": { "schema": { - "required": [ - "key" - ], - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": [ - "string", - "null" - ] - }, - "is_literal": { - "type": "boolean" - }, - "is_multiline": { - "type": "boolean" - }, - "is_shown_once": { - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", - "type": "boolean" - }, - "comment": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" } } } @@ -22340,6 +22310,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -22415,6 +22395,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -22511,6 +22501,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -22604,6 +22604,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -22718,6 +22728,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -22793,6 +22813,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -22889,6 +22919,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -24732,6 +24772,72 @@ }, "type": "object" }, + "SharedEnvironmentVariableCreateRequest": { + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_shown_once": { + "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "type": "boolean" + }, + "comment": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "type": "object" + }, + "SharedEnvironmentVariableUpdateRequest": { + "description": "Send at least one field.", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_shown_once": { + "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "type": "boolean" + }, + "comment": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "type": "object" + }, "VolumeBackupScheduleRequest": { "required": [ "frequency" diff --git a/openapi.yaml b/openapi.yaml index 743cf4b7ee..c5ec7a7029 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14008,23 +14008,7 @@ paths: content: application/json: schema: - required: - - key - properties: - key: - type: string - value: - type: [string, 'null'] - is_literal: - type: boolean - is_multiline: - type: boolean - is_shown_once: - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' - type: boolean - comment: - type: [string, 'null'] - type: object + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -14076,6 +14060,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -14127,6 +14117,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -14194,6 +14190,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -14259,6 +14261,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -14340,6 +14348,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -14391,6 +14405,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -14458,6 +14478,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -15702,6 +15728,51 @@ components: preview: $ref: '#/components/schemas/ApplicationPreview' type: object + SharedEnvironmentVariableCreateRequest: + required: + - key + properties: + key: + type: string + value: + type: + - string + - 'null' + is_literal: + type: boolean + is_multiline: + type: boolean + is_shown_once: + description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + type: boolean + comment: + type: + - string + - 'null' + maxLength: 256 + type: object + SharedEnvironmentVariableUpdateRequest: + description: 'Send at least one field.' + properties: + key: + type: string + value: + type: + - string + - 'null' + is_literal: + type: boolean + is_multiline: + type: boolean + is_shown_once: + description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + type: boolean + comment: + type: + - string + - 'null' + maxLength: 256 + type: object VolumeBackupScheduleRequest: required: - frequency diff --git a/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php new file mode 100644 index 0000000000..1ed2a710af --- /dev/null +++ b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php @@ -0,0 +1,37 @@ +toBeTrue() + ->and($operation['requestBody']['content']['application/json']['schema']['$ref'] ?? null) + ->toBe("#/components/schemas/{$schemaName}"); +})->with([ + ['/team/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/team/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/projects/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/projects/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/servers/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/servers/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], +]); + +it('documents the fields accepted by the shared env validator', function () { + $schemas = sharedEnvOpenApiSpec()['components']['schemas']; + $fields = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']; + + expect(array_keys($schemas['SharedEnvironmentVariableCreateRequest']['properties']))->toBe($fields) + ->and($schemas['SharedEnvironmentVariableCreateRequest']['required'])->toBe(['key']) + ->and(array_keys($schemas['SharedEnvironmentVariableUpdateRequest']['properties']))->toBe($fields) + ->and($schemas['SharedEnvironmentVariableUpdateRequest'])->not->toHaveKey('required'); +}); From dce02411a032567f71addf8fef252822c6f2c493 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:38:21 +0100 Subject: [PATCH 12/18] fix(service): keep WordPress URLs in sync with the Coolify domain Define WP_HOME and WP_SITEURL from SERVICE_URL_WORDPRESS in the MariaDB and MySQL templates, so a domain change no longer makes the login redirect to the domain used at install time. Closes #12406 Co-Authored-By: Claude Opus 5.5 --- templates/compose/wordpress-with-mariadb.yaml | 1 + templates/compose/wordpress-with-mysql.yaml | 1 + templates/service-templates-latest.json | 8 ++++---- templates/service-templates.json | 8 ++++---- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/templates/compose/wordpress-with-mariadb.yaml b/templates/compose/wordpress-with-mariadb.yaml index 7822a05539..c1db1c6535 100644 --- a/templates/compose/wordpress-with-mariadb.yaml +++ b/templates/compose/wordpress-with-mariadb.yaml @@ -16,6 +16,7 @@ services: - WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS - WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS - WORDPRESS_DB_NAME=wordpress + - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }" depends_on: - mariadb healthcheck: diff --git a/templates/compose/wordpress-with-mysql.yaml b/templates/compose/wordpress-with-mysql.yaml index 269208e514..65aa38d411 100644 --- a/templates/compose/wordpress-with-mysql.yaml +++ b/templates/compose/wordpress-with-mysql.yaml @@ -16,6 +16,7 @@ services: - WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS - WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS - WORDPRESS_DB_NAME=wordpress + - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }" depends_on: - mysql healthcheck: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index b1bf9f973c..e394176477 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1148,7 +1148,7 @@ "category": "devtools", "logo": "svgs/dozzle.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-18T12:35:50+02:00", + "template_last_updated_at": "2026-10-26T00:01:07+01:00", "port": "8080" }, "dozzle": { @@ -2457,7 +2457,7 @@ "category": "productivity", "logo": "svgs/hi-events.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-10-29T18:55:45+01:00", "port": "80" }, "homarr": { @@ -5967,7 +5967,7 @@ "wordpress-with-mariadb": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICAgIC0gIldPUkRQUkVTU19DT05GSUdfRVhUUkE9aWYgKGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX1VSTF9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyB9IgogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "cms", "blog", @@ -5984,7 +5984,7 @@ "wordpress-with-mysql": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKSB7IGRlZmluZSgnV1BfSE9NRScsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "cms", "blog", diff --git a/templates/service-templates.json b/templates/service-templates.json index 2a9588fd96..153f55ef96 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1148,7 +1148,7 @@ "category": "devtools", "logo": "svgs/dozzle.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-18T12:35:50+02:00", + "template_last_updated_at": "2026-10-26T00:01:07+01:00", "port": "8080" }, "dozzle": { @@ -2457,7 +2457,7 @@ "category": "productivity", "logo": "svgs/hi-events.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-10-29T18:55:45+01:00", "port": "80" }, "homarr": { @@ -5967,7 +5967,7 @@ "wordpress-with-mariadb": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSkgeyBkZWZpbmUoJ1dQX0hPTUUnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9GUUROX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "cms", "blog", @@ -5984,7 +5984,7 @@ "wordpress-with-mysql": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgICAgLSAiV09SRFBSRVNTX0NPTkZJR19FWFRSQT1pZiAoZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IH0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "cms", "blog", From 1af7de8a8580f556f48e7102239f1646c76d012f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:38:49 +0100 Subject: [PATCH 13/18] fix(backups): pin a reachable IP for S3 uploads on IPv6-only servers mc keeps one --resolve IP per host, and Coolify always pinned the IPv4 address of a dual-stack endpoint. On servers without IPv4, uploads failed with "network is unreachable". A helper container on the upload network now connects to each validated IP, IPv4 first, and the first IP that connects is pinned. S3 restores are now pinned in the same way. Closes #12397 Co-Authored-By: Claude Opus 5.5 --- app/Actions/Database/StartDatabaseImport.php | 21 +++-- app/Jobs/DatabaseBackupJob.php | 6 +- app/Jobs/VolumeBackupJob.php | 27 ++---- app/Models/S3Storage.php | 42 +++++++++ app/Rules/SafeWebhookUrl.php | 20 ++--- .../S3StorageMinioClientResolveTest.php | 89 +++++++++++++++++++ .../StartDatabaseImportS3CredentialsTest.php | 37 ++++++++ tests/Unit/SafeWebhookUrlTest.php | 7 +- 8 files changed, 204 insertions(+), 45 deletions(-) create mode 100644 tests/Feature/S3StorageMinioClientResolveTest.php diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index f8c3c861ba..b4b12ec517 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -159,10 +159,10 @@ class StartDatabaseImport throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); } $helper = "s3-restore-{$operation}"; - $this->startS3Helper($storage, $server, $helper, $network); + $resolveOptions = $this->startS3Helper($storage, $server, $helper, $network); $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}"); $commandList = [ - 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore', + 'docker exec '.escapeshellarg($helper).' mc cp'.$resolveOptions.' '.$sourceArg.' /tmp/restore', ...$this->streamFromHelper($helper, $container, $containerPath), 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', ]; @@ -294,10 +294,12 @@ class StartDatabaseImport * Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the * stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or * on the server. The helper is removed when either step fails. + * + * @return string The mc `--resolve` option that pins the endpoint host. */ - private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void + private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): string { - $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); + $image = coolifyHelperImage().':'.getHelperVersion(); try { instant_remote_process([ @@ -305,9 +307,12 @@ class StartDatabaseImport 'docker run -d --network '.escapeshellarg($network) .' --name '.escapeshellarg($helper) .' '.$storage->minioClientRegionOption() - .' '.$image.' sleep 86400', + .' '.escapeshellarg($image).' sleep 86400', ], $server); - $this->setMinioClientAlias($storage, $server, $helper); + $resolveOptions = $storage->minioClientResolveOptions($server, $image, $network); + $this->setMinioClientAlias($storage, $server, $helper, $resolveOptions); + + return $resolveOptions; } catch (Throwable) { instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false); @@ -315,10 +320,10 @@ class StartDatabaseImport } } - private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void + private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper, string $resolveOptions): void { $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper) - .' mc alias set'.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; + .' mc alias set'.$resolveOptions.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; $timeout = (int) config('constants.ssh.command_timeout'); SshRetryHandler::retry( diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 02094a9dc2..6ffcda6ed4 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -20,7 +20,6 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; -use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; @@ -934,10 +933,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) - ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $resolveOptions = $s3->minioClientResolveOptions($this->server, $fullImageName, $network); instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions); diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 42f209d1e5..90f75c8c6b 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -13,7 +13,6 @@ use App\Models\Server; use App\Models\VolumeBackupS3Replica; use App\Notifications\VolumeBackup\BackupFailed; use App\Notifications\VolumeBackup\BackupSuccess; -use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; @@ -104,7 +103,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = self::archiveScript($compressionCpuPercentage); if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $containerName); + $streamS3 = $destinations->first(); + $streamS3->testConnection(shouldSave: true); + $streamResolveOptions = $streamS3->minioClientResolveOptions($server, $image); + $archiveCommand = $this->streamToS3Command($streamS3, $archiveScript, $backupLocation, $containerName, $streamResolveOptions); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -128,7 +130,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue try { if ($streamToS3) { - $this->startStreamHelper($destinations->first(), $verifySourceCommand, $source, $containerName, $image, $server); + $this->startStreamHelper($streamS3, $verifySourceCommand, $source, $containerName, $image, $server, $streamResolveOptions); } $archiveOutput = instant_remote_process(array_filter([ $verifySourceCommand, @@ -440,7 +442,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $resolveOptions = $this->minioClientResolveOptions($s3); + $resolveOptions = $s3->minioClientResolveOptions($server, $image); try { instant_remote_process([ @@ -467,7 +469,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue * Starts the helper container that streams the volume to S3 and sets its mc alias. The archive then * runs in it with `docker exec`, so the S3 keys are never part of a command line. */ - private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server): void + private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server, string $resolveOptions): void { instant_remote_process([ $verifySourceCommand, @@ -477,7 +479,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue .' -v '.escapeshellarg($source.':/volume:ro') .' '.escapeshellarg($image).' >/dev/null', ], $server, timeout: $this->timeout, disableMultiplexing: true); - $this->setMinioClientAlias($server, $containerName, $s3, $this->minioClientResolveOptions($s3)); + $this->setMinioClientAlias($server, $containerName, $s3, $resolveOptions); } /** @@ -506,19 +508,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ); } - private function minioClientResolveOptions(S3Storage $s3): string + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName, string $resolveOptions): string { - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) - ->implode(' '); - - return $resolveOptions === '' ? '' : ' '.$resolveOptions; - } - - private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName): string - { - $s3->testConnection(shouldSave: true); - $resolveOptions = $this->minioClientResolveOptions($s3); $destination = 'temporary/'.$s3->bucket.$backupLocation; // `mc pipe --quiet` panics in recent mc releases, so keep its progress bar out of the error output instead. // Each progress frame starts with "\r", so printing the last field per line keeps only the final frame and real errors. diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 84bf072dc1..eabe8f322c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -44,6 +44,11 @@ class S3Storage extends BaseModel */ private const REMOTE_COMMAND_TIMEOUT_EXIT_CODE = 124; + /** + * How long the helper container waits for a TCP connection to one IP of the endpoint. + */ + private const RESOLVE_PROBE_TIMEOUT_SECONDS = 5; + protected $fillable = [ 'team_id', 'name', @@ -369,6 +374,43 @@ class S3Storage extends BaseModel return ' --api S3v4'.($this->usesPathStyleEndpoint() ? '' : ' --path off'); } + /** + * The mc `--resolve` option that pins the endpoint host to one of its validated IPs. mc keeps one IP per host, + * so when the host has more than one IP, a helper container on the upload network connects to each IP, IPv4 + * first, and the first IP that connects is used. A server without IPv4 then uses an IPv6 address of a + * dual-stack endpoint. When no IP connects, the first IP is used, so mc reports the real connection error. + */ + public function minioClientResolveOptions(Server $server, string $image, ?string $network = null): string + { + $entries = SafeWebhookUrl::minioClientResolveOptions($this->endpoint, $this->trustedInternalHosts()); + if ($entries === []) { + return ''; + } + + $entry = $entries[0]; + if (count($entries) > 1) { + $ips = array_map(fn (string $entry): string => str($entry)->afterLast('=')->toString(), $entries); + $port = (int) str($entries[0])->beforeLast('=')->afterLast(':')->toString(); + $probe = 'for ip in '.implode(' ', array_map('escapeshellarg', $ips)).'; do' + .' if timeout '.self::RESOLVE_PROBE_TIMEOUT_SECONDS.' bash -c "exec 3<>/dev/tcp/$ip/'.$port.'" 2>/dev/null; then echo "$ip"; exit 0; fi;' + .' done'; + $reachableIp = trim((string) instant_remote_process( + ['docker run --rm'.($network === null ? '' : ' --network '.escapeshellarg($network)) + .' '.escapeshellarg($image).' bash -c '.escapeshellarg($probe)], + $server, + throwError: false, + timeout: count($ips) * self::RESOLVE_PROBE_TIMEOUT_SECONDS + 60, + disableMultiplexing: true, + )); + $reachableIndex = array_search($reachableIp, $ips, true); + if ($reachableIndex !== false) { + $entry = $entries[$reachableIndex]; + } + } + + return ' --resolve '.escapeshellarg($entry); + } + /** * Runs an upload of a local file with the MinIO client again when it fails, for example after a short network * outage. A timeout is not retried, because another full attempt can pass the timeout of the backup job. diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index c872dde82b..23010dc6d5 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -194,7 +194,7 @@ class SafeWebhookUrl implements ValidationRule } /** - * Build mc --resolve mappings that pin the endpoint host for S3 backups. + * Build mc --resolve mappings, IPv4 first, that pin the endpoint host for S3 backups. * * @return array */ @@ -206,17 +206,17 @@ class SafeWebhookUrl implements ValidationRule return []; } - // The MinIO client keeps one --resolve IP per host and parses it with Go's - // netip.ParseAddr, which rejects bracketed IPv6. Emit a single entry, preferring - // IPv4 for reachability, without brackets. - $preferredIps = array_values(array_filter( - $target['ips'], - fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false, - )); + // mc parses --resolve values with Go's netip.ParseAddr, which rejects bracketed IPv6. + // mc also keeps one IP per host, so S3Storage::minioClientResolveOptions() picks one entry. + $ips = $target['ips']; + usort($ips, fn (string $a, string $b): int => self::isIpv6($a) <=> self::isIpv6($b)); - $ip = $preferredIps[0] ?? $target['ips'][0]; + return array_map(fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], $ip), $ips); + } - return [sprintf('%s:%d=%s', $target['host'], $target['port'], $ip)]; + private static function isIpv6(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; } public static function redactedUrlForLog(string $url): string diff --git a/tests/Feature/S3StorageMinioClientResolveTest.php b/tests/Feature/S3StorageMinioClientResolveTest.php new file mode 100644 index 0000000000..69fe5e0967 --- /dev/null +++ b/tests/Feature/S3StorageMinioClientResolveTest.php @@ -0,0 +1,89 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ])); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * @return array{0: Server, 1: S3Storage} + */ +function minioResolveTestSetup(string $endpoint, string $sshUser = 'root'): array +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'user' => $sshUser, + 'ip' => '203.0.113.10', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $storage = S3Storage::create([ + 'name' => 'Destination', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => $endpoint, + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + return [$server, $storage]; +} + +it('pins the IPv6 address of a dual-stack S3 host when only IPv6 connects from the upload network', function () { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000'); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + + $options = $storage->minioClientResolveOptions($server, 'ghcr.io/coollabsio/coolify-helper:1.0.0', 'coolify'); + + expect($options)->toBe(" --resolve 'localhost:9000=::1'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify' 'ghcr.io/coollabsio/coolify-helper:1.0.0' bash -c") + && str_contains(remoteProcessScript($process), "for ip in '\\''127.0.0.1'\\'' '\\''::1'\\''; do") + && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000')); +}); + +it('pins the IPv4 address when it connects or when no address connects', function (string $probeOutput) { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000'); + fakeRemoteProcesses(['*/dev/tcp/*' => $probeOutput, '*' => '']); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=127.0.0.1'"); +})->with([ + 'IPv4 connects' => '127.0.0.1', + 'no address connects' => '', + 'unexpected output' => '10.0.0.1', +]); + +it('runs the probe with sudo on servers with a non-root SSH user', function () { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000', 'deploy'); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=::1'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo docker run --rm 'helper:1.0.0' bash -c") + && str_contains(remoteProcessScript($process), 'if timeout 5 bash -c "exec 3<>/dev/tcp/$ip/9000" 2>/dev/null; then echo "$ip"; exit 0; fi; done')); +}); + +it('does not pin or probe an S3 endpoint that is an IP address', function () { + [$server, $storage] = minioResolveTestSetup('https://8.8.8.8'); + Process::fake(); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(''); + Process::assertNothingRan(); +}); diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php index 15b0041535..045d5c1db9 100644 --- a/tests/Feature/StartDatabaseImportS3CredentialsTest.php +++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php @@ -282,6 +282,43 @@ function importS3ArgvTestRunAliasProcess(array $aliasProcess): array } } +it('pins the reachable IP of a dual-stack S3 host for the restore mc commands', function () { + InstanceSettings::query()->whereKey(0)->update([ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ]); + $storage = S3Storage::create([ + 'name' => 'Import S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'http://localhost:9000', + 'is_usable' => true, + 'team_id' => $this->team->id, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('exists')->once()->andReturn(true); + $disk->shouldReceive('size')->once()->andReturn(1024); + $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial(); + $filesystem->shouldReceive('build')->once()->andReturn($disk); + Storage::swap($filesystem); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + Queue::fake(); + + $activity = app(StartDatabaseImport::class)->handle( + $this->database, + new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid), + $this->team->id, + ); + + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify'") + && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mc alias set --resolve '\\''localhost:9000=::1'\\'' --api S3v4 s3temp")); + expect((string) RemoteProcessCommand::read($activity)) + ->toContain("mc cp --resolve 'localhost:9000=::1' ".escapeshellarg('s3temp/test-bucket/backups/restore.sql')); +}); + it('passes the S3 keys of an import to mc on stdin, not in a command line', function (string $sshUser) { [$processes, $activity, $error] = importS3ArgvTestRun($this, $sshUser); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 83d8c9557c..abd7e85937 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -278,17 +278,16 @@ it('builds MinIO client resolve options for S3 backup uploads', function () { expect($options)->toContain('localhost:9000=127.0.0.1'); }); -it('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () { +it('lists every S3 host IP as a bracket-free resolve entry, IPv4 first', function () { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ 'webhook_allowed_internal_hosts' => ['localhost'], 'webhook_allow_localhost' => true, ])); - // localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per - // host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced. + // mc rejects bracketed IPv6, and S3Storage::minioClientResolveOptions() picks the reachable entry. $options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000'); - expect($options)->toBe(['localhost:9000=127.0.0.1']); + expect($options)->toBe(['localhost:9000=127.0.0.1', 'localhost:9000=::1']); }); it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () { From 8dea52609803f95a55716a08ace41c1ce47c2bc1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:44:20 +0100 Subject: [PATCH 14/18] Revert "fix(service): keep WordPress URLs in sync with the Coolify domain" Do not overwrite the WordPress site address from the Coolify domain. WordPress owns this setting; the docs now explain how to change it. This reverts commit dce02411a. Co-Authored-By: Claude Opus 5.5 --- templates/compose/wordpress-with-mariadb.yaml | 1 - templates/compose/wordpress-with-mysql.yaml | 1 - templates/service-templates-latest.json | 8 ++++---- templates/service-templates.json | 8 ++++---- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/templates/compose/wordpress-with-mariadb.yaml b/templates/compose/wordpress-with-mariadb.yaml index c1db1c6535..7822a05539 100644 --- a/templates/compose/wordpress-with-mariadb.yaml +++ b/templates/compose/wordpress-with-mariadb.yaml @@ -16,7 +16,6 @@ services: - WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS - WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS - WORDPRESS_DB_NAME=wordpress - - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }" depends_on: - mariadb healthcheck: diff --git a/templates/compose/wordpress-with-mysql.yaml b/templates/compose/wordpress-with-mysql.yaml index 65aa38d411..269208e514 100644 --- a/templates/compose/wordpress-with-mysql.yaml +++ b/templates/compose/wordpress-with-mysql.yaml @@ -16,7 +16,6 @@ services: - WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS - WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS - WORDPRESS_DB_NAME=wordpress - - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }" depends_on: - mysql healthcheck: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index e394176477..b1bf9f973c 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1148,7 +1148,7 @@ "category": "devtools", "logo": "svgs/dozzle.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-26T00:01:07+01:00", + "template_last_updated_at": "2026-08-18T12:35:50+02:00", "port": "8080" }, "dozzle": { @@ -2457,7 +2457,7 @@ "category": "productivity", "logo": "svgs/hi-events.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-29T18:55:45+01:00", + "template_last_updated_at": null, "port": "80" }, "homarr": { @@ -5967,7 +5967,7 @@ "wordpress-with-mariadb": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICAgIC0gIldPUkRQUkVTU19DT05GSUdfRVhUUkE9aWYgKGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX1VSTF9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyB9IgogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "cms", "blog", @@ -5984,7 +5984,7 @@ "wordpress-with-mysql": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKSB7IGRlZmluZSgnV1BfSE9NRScsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "cms", "blog", diff --git a/templates/service-templates.json b/templates/service-templates.json index 153f55ef96..2a9588fd96 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1148,7 +1148,7 @@ "category": "devtools", "logo": "svgs/dozzle.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-26T00:01:07+01:00", + "template_last_updated_at": "2026-08-18T12:35:50+02:00", "port": "8080" }, "dozzle": { @@ -2457,7 +2457,7 @@ "category": "productivity", "logo": "svgs/hi-events.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-29T18:55:45+01:00", + "template_last_updated_at": null, "port": "80" }, "homarr": { @@ -5967,7 +5967,7 @@ "wordpress-with-mariadb": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSkgeyBkZWZpbmUoJ1dQX0hPTUUnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9GUUROX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "cms", "blog", @@ -5984,7 +5984,7 @@ "wordpress-with-mysql": { "documentation": "https://wordpress.org?utm_source=coolify.io", "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgICAgLSAiV09SRFBSRVNTX0NPTkZJR19FWFRSQT1pZiAoZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IH0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "cms", "blog", From bc855fdc68ed5ae77eb23579f92bb504b649334d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:46:19 +0100 Subject: [PATCH 15/18] fix(transfer): resolve IPv6-only server host names The domain check of the transfer checklist resolved the server host name with gethostbynamel(), which returns IPv4 only, so a domain that points to an IPv6-only server was reported as pointing to another address. Resolve A and AAAA records and compare normalized addresses. The Cloud SSH probe now also resolves AAAA records when a host name has no A record. Closes #12397 Co-Authored-By: Claude Opus 5.5 --- .../Api/ServerTransferController.php | 2 +- .../TransferFinishChecklist.php | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php index efd8f428b7..0dc0229a30 100644 --- a/app/Http/Controllers/Api/ServerTransferController.php +++ b/app/Http/Controllers/Api/ServerTransferController.php @@ -232,7 +232,7 @@ class ServerTransferController extends Controller Server::throttleValidation($teamId, $ip); $probeIp = Server::normalizeIp($ip); if (isCloud() && ! filter_var($probeIp, FILTER_VALIDATE_IP)) { - $probeIp = gethostbyname($probeIp); + $probeIp = resolveHostToIp($probeIp); } $probeHost = filter_var($probeIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? "[{$probeIp}]" : $probeIp; $probeAllowed = ! isCloud() || (filter_var($probeIp, FILTER_VALIDATE_IP) diff --git a/app/Services/ServerTransfer/TransferFinishChecklist.php b/app/Services/ServerTransfer/TransferFinishChecklist.php index d894b49cb3..25d6d19e61 100644 --- a/app/Services/ServerTransfer/TransferFinishChecklist.php +++ b/app/Services/ServerTransfer/TransferFinishChecklist.php @@ -407,14 +407,29 @@ class TransferFinishChecklist : ['action', 'This instance does not own the record yet.', 'Until it owns the record, this instance does not change or delete it.']; } + /** + * The IPv4 and IPv6 addresses of a host name, in the normalized form of normalizeIpAddress(). + * + * @return array + */ + private function resolveIps(string $host): array + { + $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: []; + + return array_values(array_filter(array_map( + fn (array $record): ?string => isset($record['ip']) || isset($record['ipv6']) ? normalizeIpAddress($record['ip'] ?? $record['ipv6']) : null, + $records, + ))); + } + /** * @return array{0: string, 1: string, 2: string|null} */ private function checkDomain(Server $server, string $host): array { - $serverIps = filter_var($server->ip, FILTER_VALIDATE_IP) ? [$server->ip] : (gethostbynamel($server->ip) ?: []); - $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: []; - $ips = array_values(array_filter(array_map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null, $records))); + $serverIp = normalizeIpAddress($server->ip); + $serverIps = filter_var($serverIp, FILTER_VALIDATE_IP) ? [$serverIp] : $this->resolveIps($serverIp); + $ips = $this->resolveIps($host); if ($ips === []) { return ['action', 'The domain does not resolve.', null]; } From 5cf1abc0fc0cbdc39276d03607c342633a8a6cc0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:49:11 +0100 Subject: [PATCH 16/18] fix(env): keep pre-upgrade values for legacy compose variables Closes #12408 Co-Authored-By: Claude Opus 5.5 --- app/Models/EnvironmentVariable.php | 6 ++- app/Traits/HasSecretManager.php | 44 ++++++++++++++-- bootstrap/helpers/shared.php | 48 ++++++++++++++++++ ...ationDeploymentControlVarFilteringTest.php | 50 +++++++++++++++++++ .../EnvironmentVariableLegacyEscapingTest.php | 34 +++++++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index b40ca8ebb3..430727a502 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -291,7 +291,11 @@ class EnvironmentVariable extends BaseModel $value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables); - return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value); + if ($value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping) { + return $value; + } + + return method_exists($resource, 'useExactEscaping') && $resource->useExactEscaping($this) ? $value : $this->legacyEscapedValue($value); } /** diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index dd9195c9af..621a29ca01 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -146,11 +146,46 @@ trait HasSecretManager * Decides the escaping of a variable for all paths (service .env, database environment, * Redis password). A variable saved before exact escaping keeps the old escaping, so its * container gets the same value as before the upgrade. Saving the variable again switches - * it to exact escaping. + * it to exact escaping. Compose references that got the stored value before the upgrade + * (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old + * .env lines that Docker Compose cannot read, because they stop the deployment. */ public function useExactEscaping(EnvironmentVariable $environmentVariable): bool { - return ! $environmentVariable->uses_legacy_escaping; + if (! $environmentVariable->uses_legacy_escaping) { + return true; + } + + if (! $this instanceof Service && ! $this instanceof Application) { + return false; + } + + if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) { + return true; + } + + $parsesCompose = $this instanceof Service + || ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled); + + return $parsesCompose + && dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key); + } + + /** + * The old escaping puts literal and multiline values in single quotes without escaping. Docker + * Compose ends the value at the next quote, so a value with a quote breaks the whole .env file. + */ + private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool + { + if (! $environmentVariable->is_literal && ! $environmentVariable->is_multiline) { + return false; + } + + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + return is_string($value) + && str_contains($value, "'") + && ! (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))); } /** @@ -210,8 +245,9 @@ trait HasSecretManager } if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { - // Service and application .env files strip the old single quotes; database environment lists keep them. - return ($this instanceof Service || $this instanceof Application) ? str_contains($value, "'") : true; + // Service and application .env files strip the old single quotes (values with a quote use exact + // escaping); database environment lists keep them. + return ! ($this instanceof Service || $this instanceof Application); } return escapeEnvVariables($value) !== $value diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 10255bcebd..e852ec50f0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5605,6 +5605,54 @@ function dockerComposeEnvironmentVariableShouldBeUnset(?string $dockerCompose, s return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key); } +/** + * Variables whose stored value the Compose parser before v4.5.0 wrote straight into the `environment:` + * list, so their containers got the value without the old .env escaping: references with a default or + * under another name (`FOO=${FOO:-}`, `BAR=${FOO}`, `BAR=$FOO`) and references in such a default. + * A plain `FOO=${FOO}` reference kept reading the escaped .env value. + * + * @return Collection + */ +function dockerComposeVariablesWithStoredValueBeforeExactEscaping(?string $dockerCompose): Collection +{ + static $cache = []; + if (blank($dockerCompose)) { + return collect(); + } + + $cacheKey = hash('xxh128', $dockerCompose); + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + + $keys = collect(); + foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) { + $value = $assignment['value'] ?? null; + if (! is_string($value) || ! str_starts_with($value, '$') || replaceVariables($value)->value() === (string) $assignment['key']) { + continue; + } + + $reference = extractBalancedBraceContent($value, 0); + if ($reference === null) { + if (preg_match('/\A\$([A-Za-z_][A-Za-z0-9_]*)\z/', $value, $match) === 1) { + $keys->push($match[1]); + } + + continue; + } + + $split = splitOnOperatorOutsideNested($reference['content']); + $keys->push($split === null ? $reference['content'] : $split['variable']); + if ($split !== null) { + $keys = $keys->concat(extractDockerComposeEnvironmentVariableReferences($split['default'])); + } + } + + $cache = [$cacheKey => $keys->reject(fn (string $key): bool => str_starts_with($key, 'SERVICE_'))->unique()->values()]; + + return $cache[$cacheKey]; +} + /** @param Collection $environmentVariables */ function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection { diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index fc3fdcf363..ebdecee8da 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1842,6 +1842,56 @@ it('keeps the old runtime env format for variables saved before exact escaping', ->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true)); }); +it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) { + $compose = <<<'YAML' +services: + app: + image: alpine + environment: + - TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-} + - PLAIN_REFERENCE=${PLAIN_REFERENCE} +YAML; + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + ]); + $application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]); + + foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) { + $variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]); + $variable->uses_legacy_escaping = true; + $variable->save(); + } + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs) + ->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault) + ->toContain('PLAIN_REFERENCE=^example\\\\.com$'); +})->with([ + 'parsed compose' => [false, '"^example\\\\.com$"'], + 'raw compose deployment' => [true, '^example\\\\.com$'], +]); + +it('writes a readable runtime env line for legacy literal values with a single quote', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + $variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]); + $variable->uses_legacy_escaping = true; + $variable->save(); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"'); +}); + it('keeps the old runtime env format for preview variables saved before exact escaping', function () { [$application, $server] = makeDeploymentControlVarFixture(); diff --git a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php index 96382a231c..6823dfc7b5 100644 --- a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php +++ b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php @@ -95,6 +95,40 @@ test('legacy service variables keep the old env file format', function () { ->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY='); }); +test('legacy service variables keep the value that the compose file got before exact escaping', function () { + $this->service->update(['docker_compose_raw' => <<<'YAML' +services: + app: + image: alpine + environment: + - WITH_DEFAULT=${WITH_DEFAULT:-x} + - OTHER_NAME=${RENAMED} + - NESTED=${NESTED:-${NESTED_DEFAULT}} + - PLAIN=${PLAIN} +YAML]); + $variables = collect(['WITH_DEFAULT', 'RENAMED', 'NESTED_DEFAULT', 'PLAIN']) + ->mapWithKeys(fn (string $key) => [$key => legacyVariable($this->service, ['key' => $key, 'value' => 'a\\.b'])]); + + expect($this->service->composeEnvironmentFileLine($variables['WITH_DEFAULT']))->toBe('WITH_DEFAULT="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['RENAMED']))->toBe('RENAMED="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['NESTED_DEFAULT']))->toBe('NESTED_DEFAULT="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['PLAIN']))->toBe('PLAIN=a\\\\.b') + ->and($this->service->legacyEscapingChangesValue($variables['WITH_DEFAULT']))->toBeFalse() + ->and($this->service->legacyEscapingChangesValue($variables['PLAIN']))->toBeTrue() + ->and($variables['WITH_DEFAULT']->fresh()->real_value)->toBe('a\\.b'); +}); + +test('legacy literal and multiline variables with a single quote get a line that docker compose can read', function () { + $literal = legacyVariable($this->service, ['key' => 'LITERAL_QUOTE', 'value' => "it's", 'is_literal' => true]); + $multiline = legacyVariable($this->service, ['key' => 'MULTILINE_QUOTE', 'value' => "it's\n\"two\"", 'is_multiline' => true]); + $withoutQuote = legacyVariable($this->service, ['key' => 'LITERAL_PLAIN', 'value' => 'x$y', 'is_literal' => true]); + + expect($this->service->composeEnvironmentFileLine($literal))->toBe('LITERAL_QUOTE="it\'s"') + ->and($this->service->composeEnvironmentFileLine($multiline))->toBe("MULTILINE_QUOTE=\"it's\n\\\"two\\\"\"") + ->and($this->service->composeEnvironmentFileLine($withoutQuote))->toBe("LITERAL_PLAIN='x\$y'") + ->and($this->service->legacyEscapingChangesValue($literal))->toBeFalse(); +}); + test('legacy database variables keep the old compose environment format', function () { $database = StandalonePostgresql::forceCreate([ 'uuid' => 'postgres-legacy-test', From 6977caaa9b4f62d58c6f76d6227cc14753c794af Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:55:38 +0100 Subject: [PATCH 17/18] fix(service): store the Compose default for bare variable references Closes #12412 --- bootstrap/helpers/parsers.php | 2 + bootstrap/helpers/shared.php | 57 +++++++++++++++++++ .../Feature/EnvironmentVariableSearchTest.php | 39 +++++++++++++ 3 files changed, 98 insertions(+) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7aa5ef279e..b723993b67 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2422,6 +2422,7 @@ function serviceParser(Service $resource): Collection ->orWhere('key', 'LIKE', '%}'); }) ->delete(); + $existingEnvironmentVariableKeys = $resource->environment_variables()->pluck('key'); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), @@ -3671,6 +3672,7 @@ function serviceParser(Service $resource): Collection data_forget($resource, 'environment_variables_preview'); $resource->save(); + fillDockerComposeDefaultsForBareReferences($resource, $existingEnvironmentVariableKeys); syncDockerComposeEnvironmentVariableRequiredState($resource); return $topLevel; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e852ec50f0..420f4322fe 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5580,6 +5580,63 @@ function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $ke ]; } +/** + * Docker Compose gives a bare $VAR or ${VAR} reference no default, and the service parser does not store + * defaults. So `/$POSTGRES_DB` next to `${POSTGRES_DB:-umami}` in another service resolves to an empty value. + * For new variables, store the default when the Compose file has a bare reference and exactly one default. + * + * @param Collection $existingKeys + */ +function fillDockerComposeDefaultsForBareReferences(Service $resource, Collection $existingKeys): void +{ + $dockerCompose = $resource->docker_compose_raw; + if (blank($dockerCompose)) { + return; + } + + $resource->environment_variables() + ->whereNull('value') + ->whereNotIn('key', $existingKeys) + ->get() + ->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void { + $default = dockerComposeBareReferenceDefault($dockerCompose, $environmentVariable->key); + // A nested default (`${A:-${B}}`) must stay with Compose: stored, it would be escaped to a literal. + if (filled($default) && ! str_contains($default, '$')) { + $environmentVariable->update(['value' => $default]); + } + }); +} + +/** The single default of a variable that the Compose file also references without a default, otherwise null. */ +function dockerComposeBareReferenceDefault(string $dockerCompose, string $key): ?string +{ + $quotedKey = preg_quote($key, '/'); + if (preg_match('/(? 0 && $dockerCompose[$referenceStart - 1] === '$') { + continue; + } + + $reference = extractBalancedBraceContent(substr($dockerCompose, $referenceStart)); + $split = $reference === null ? null : splitOnOperatorOutsideNested($reference['content']); + if ($split === null || $split['variable'] !== $key) { + continue; + } + if (str_contains($split['operator'], '?')) { + return null; + } + $defaults[$split['default']] = true; + } + + return count($defaults) === 1 ? (string) array_key_first($defaults) : null; +} + function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool { if (blank($dockerCompose)) { diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index 67c713cad1..9b522e060c 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -396,6 +396,45 @@ YAML; ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); }); +it('stores the Compose default for a variable that another service references without one', function (string $dockerCompose) { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $dockerCompose, + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'POSTGRES_DB')->value('value'))->toBe('umami'); +})->with([ + 'reference before default' => "services:\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\$POSTGRES_DB\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n", + 'default before reference' => "services:\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\${POSTGRES_DB}\n", +]); + +it('does not store a nested Compose default for a bare reference', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n DB: \${DB:-\${APP_NAME}}\n URL: postgres://db/\$DB\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'DB')->firstOrFail()->value)->toBeNull(); +}); + +it('does not store a Compose default when the defaults for a bare reference conflict', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-info}\n LOG: level=\$LEVEL\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull(); +}); + it('keeps editable and literal rows when services use the same key differently', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, From 6ef29c64a10f66e0a614219b12f9f7f9867e368e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:57:48 +0100 Subject: [PATCH 18/18] fix(cdn): block releases sync before the current release is published Co-Authored-By: Claude Opus 5.5 --- app/Console/Commands/SyncCdn.php | 7 +++++++ tests/Feature/SyncCdnTest.php | 32 +++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/SyncCdn.php b/app/Console/Commands/SyncCdn.php index c447c5f726..97319997e2 100644 --- a/app/Console/Commands/SyncCdn.php +++ b/app/Console/Commands/SyncCdn.php @@ -103,6 +103,13 @@ class SyncCdn extends Command return false; } + $currentTag = 'v'.config('constants.coolify.version'); + if (! $nightly && ! collect($response->json())->contains('tag_name', $currentTag)) { + $this->error("GitHub release {$currentTag} is not published yet. Publish it first, or the What's New page will not show it."); + + return false; + } + $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-'); if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) { $this->error('Failed to create temporary releases.json.'); diff --git a/tests/Feature/SyncCdnTest.php b/tests/Feature/SyncCdnTest.php index 52e0ea8945..5b40372985 100644 --- a/tests/Feature/SyncCdnTest.php +++ b/tests/Feature/SyncCdnTest.php @@ -51,7 +51,9 @@ it('only removes validated Coolify CDN temporary directories', function () { it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) { Http::fake([ - 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200), + 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([ + ['tag_name' => 'v'.config('constants.coolify.version')], + ], 200), ]); $binDir = sys_get_temp_dir().'/sync-cdn-bin-'.uniqid(); @@ -152,6 +154,34 @@ SH); ]], ]); +it('does not sync production releases before the current release is published', function () { + Http::fake([ + 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([['tag_name' => 'v0.0.1']], 200), + ]); + + $this->artisan('sync:cdn') + ->expectsChoice('Which environment would you like to sync?', 'production', [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ]) + ->expectsChoice('Which files would you like to sync?', ['json/coolify/releases.json'], [ + 'json/coolify/releases.json', + 'json/coolify/versions.json', + 'json/coolify/docker-compose.yml', + 'json/coolify/docker-compose.prod.yml', + 'json/coolify/.env.production', + 'json/coolify/install.sh', + 'json/coolify/upgrade.sh', + 'json/coolify/upgrade-postgres.sh', + 'json/coolify/patch.sh', + 'json/coolify/service-templates-latest.json', + ]) + ->expectsOutputToContain('is not published yet') + ->doesntExpectOutputToContain('After the CDN pull request is merged'); + + Http::assertSentCount(1); +}); + it('purges the CDN files from the Cloudflare cache', function (string $environment, array $cdnDirectories) { config()->set('constants.cloudflare.api_token', 'test-token'); config()->set('constants.cloudflare.zone_id', 'test-zone');