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();