From 2dcf8eeca3b26fdec7b5b34591217b433d68c2f0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:10:27 +0100 Subject: [PATCH] fix(previews): keep production values for previews from before the upgrade Public pull request previews no longer get production values, and no preview falls back to production variables. Existing previews were deployed with these values; for example, their databases were initialized with the production password, so they broke on redeploy. Previews that exist at the upgrade get keeps_production_values and keep the earlier behavior. Previews created later do not. Server transfer moves the flag. --- app/Jobs/ApplicationDeploymentJob.php | 10 ++++++- app/Models/ApplicationPreview.php | 2 ++ .../ServerTransfer/ServerTransferExporter.php | 1 + .../ServerTransfer/ServerTransferImporter.php | 1 + bootstrap/helpers/parsers.php | 7 +++-- ...n_values_to_application_previews_table.php | 30 +++++++++++++++++++ docs/server-transfer-full-coverage.md | 2 +- ...ationDeploymentControlVarFilteringTest.php | 26 ++++++++++++++++ .../ComposePreviewMagicVariablesTest.php | 21 +++++++++++++ .../ServerTransferExporterImporterTest.php | 2 ++ 10 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_10_28_103619_add_keeps_production_values_to_application_previews_table.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 944f9b3bb5..70931ece2e 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2018,6 +2018,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push($this->runtimeEnvironmentAssignment($env)); } + // A preview from before v4.5.1 also gets the production variables that have no preview copy. + if ($this->preview?->keeps_production_values && $runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->all(); + $sorted_environment_variables + ->filter(fn (EnvironmentVariable $env) => $env->is_runtime && ! in_array($env->key, $previewKeys, true) && ! $this->shouldOmitBlankComposeEnvironmentVariable($env)) + ->each(fn (EnvironmentVariable $env) => $envs->push($this->runtimeEnvironmentAssignment($env))); + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) { @@ -2619,7 +2627,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->previewVariableIdsWithProductionValues; } - if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled) { + if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled || $this->preview?->keeps_production_values) { return $this->previewVariableIdsWithProductionValues = []; } diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 22b1856814..2309fb128e 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -34,6 +34,7 @@ class ApplicationPreview extends BaseModel 'last_online_at', 'domain_dns_statuses', 'domain_port_overrides', + 'keeps_production_values', ]; protected $hidden = [ @@ -44,6 +45,7 @@ class ApplicationPreview extends BaseModel 'pull_request_id' => 'integer', 'domain_dns_statuses' => 'array', 'domain_port_overrides' => 'array', + 'keeps_production_values' => 'boolean', ]; protected static function booted(): void diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 66254ae647..20957defe6 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -1644,6 +1644,7 @@ class ServerTransferExporter 'docker_registry_image_tag' => $preview->docker_registry_image_tag, 'domain_port_overrides' => $preview->domain_port_overrides, 'max_restart_count' => $preview->max_restart_count, + 'keeps_production_values' => $preview->keeps_production_values, 'persistent_storages' => method_exists($preview, 'persistentStorages') ? $this->exportPersistentStorages($preview->persistentStorages) : [], diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 7362160471..1cc4aed13e 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -2333,6 +2333,7 @@ class ServerTransferImporter 'docker_registry_image_tag' => data_get($previewPayload, 'docker_registry_image_tag'), 'domain_port_overrides' => data_get($previewPayload, 'domain_port_overrides'), 'max_restart_count' => (int) data_get($previewPayload, 'max_restart_count', 0), + 'keeps_production_values' => (bool) data_get($previewPayload, 'keeps_production_values', false), ]; if ($preview) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 041e56ff0a..7aa5ef279e 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1150,8 +1150,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; - // A public preview can run the compose file of a fork pull request. - $isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled'); + $pullRequestPreview = $isPullRequest + ? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first()) + : null; + // A public preview can run the compose file of a fork pull request. A preview from before v4.5.1 keeps the production values. + $isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled') && ! $pullRequestPreview?->keeps_production_values; $server = data_get($resource, 'destination.server'); try { $yaml = parseDockerComposeYaml($compose); diff --git a/database/migrations/2026_10_28_103619_add_keeps_production_values_to_application_previews_table.php b/database/migrations/2026_10_28_103619_add_keeps_production_values_to_application_previews_table.php new file mode 100644 index 0000000000..3b187d1502 --- /dev/null +++ b/database/migrations/2026_10_28_103619_add_keeps_production_values_to_application_previews_table.php @@ -0,0 +1,30 @@ +boolean('keeps_production_values')->default(false); + }); + + DB::table('application_previews')->update(['keeps_production_values' => true]); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('keeps_production_values'); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index 25a7835e03..3391120322 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -103,7 +103,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ✅ | Volume backup | on the persistent volume: S3 on, stop during backup, all retention values, timeout, missing-backup days | | ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout | | ✅ | Tag | | -| ✅ | Preview deployment | PR preview with domain port overrides + max restart count (needs the GitHub App, or create it in the database) | +| ✅ | Preview deployment | PR preview with domain port overrides + max restart count + keeps production values (previews from before v4.5.1) (needs the GitHub App, or create it in the database) | | ✅ | Secret manager link | the app loads variables from the secret manager token | | ✅ | Managed DNS record | owned record in the Cloudflare zone for the app domain. Expected: the target takes ownership (provider comment changes) | diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index caf92c69e4..4abe239679 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -2050,3 +2050,29 @@ it('does not warn about a PORT mismatch when readonly labels are disabled', func expect(collect($job->recordedLogEntries)->contains(fn (string $entry) => str_contains($entry, 'PORT environment variable')))->toBeFalse(); }); + +it('gives a preview from before v4.5.1 the production values it had', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + $application->settings()->update(['is_pr_deployments_public_enabled' => true]); + + createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'APP_NAME', 'value' => 'coolify', 'is_buildtime' => false]); + $application->environment_variables_preview()->where('key', 'APP_NAME')->delete(); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'pull_request_id' => 99, + 'remote_secrets_cache' => [], + 'preview' => ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 99, + 'pull_request_html_url' => 'https://example.com/pr/99', + 'keeps_production_values' => true, + ]), + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=') && str($env)->contains('production-secret')))->toBeTrue() + ->and($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=') && str($env)->contains('coolify')))->toBeTrue(); +}); diff --git a/tests/Feature/ComposePreviewMagicVariablesTest.php b/tests/Feature/ComposePreviewMagicVariablesTest.php index fcdf777695..d7c393c011 100644 --- a/tests/Feature/ComposePreviewMagicVariablesTest.php +++ b/tests/Feature/ComposePreviewMagicVariablesTest.php @@ -215,3 +215,24 @@ it('replaces an existing preview copy of a production magic value for a public p ->not->toBeEmpty() ->not->toBe($production['SERVICE_PASSWORD_DB']); }); + +it('keeps the production magic value of a public pull request preview from before v4.5.1', function () { + composePreviewMagicParse($this->application); + $production = composePreviewMagicValues($this->application)['production']; + $this->application->environment_variables_preview()->create([ + 'key' => 'SERVICE_PASSWORD_DB', + 'value' => $production['SERVICE_PASSWORD_DB'], + 'is_preview' => true, + ]); + ApplicationPreview::query()->create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/7', + 'keeps_production_values' => true, + ]); + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe($production['SERVICE_PASSWORD_DB']); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 63f416eb6b..c5a55af722 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -859,6 +859,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip', 'fqdn' => 'https://pr-42.app.example.com', 'status' => 'running:healthy', 'git_type' => 'github', + 'keeps_production_values' => true, ]); $preview->uuid = 'preview-pr-42'; $preview->save(); @@ -969,6 +970,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip', expect($importedApp)->not->toBeNull() ->and($importedApp->previews)->toHaveCount(1) ->and($importedApp->previews->first()->uuid)->toBe('preview-pr-42') + ->and($importedApp->previews->first()->keeps_production_values)->toBeTrue() ->and($importedApp->previews->first()->persistentStorages)->toHaveCount(1); $importedVolBackup = ScheduledVolumeBackup::where('uuid', 'svc-app-vol-backup')->first();