diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d954a62f7c..e0dc49fdc6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -156,6 +156,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue /** @var array|null */ private ?array $remote_secrets_cache = null; + /** @var array|null */ + private ?array $previewVariableIdsWithProductionValues = null; + private ?bool $has_secret_manager_source = null; private $env_nixpacks_args; @@ -1741,10 +1744,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key'); - $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('key'); } else { $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id'); - $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('id'); } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); @@ -1881,33 +1884,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push($this->runtimeEnvironmentAssignment($env)); } - // Fall back to production env vars for keys not overridden by preview vars, - // but only when preview vars are configured. This ensures variables like - // DB_PASSWORD that are only set for production will be available in the - // preview .env file (fixing ${VAR} interpolation in docker-compose YAML), - // while avoiding leaking production values when previews aren't configured. - if ($runtime_environment_variables_preview->isNotEmpty()) { - $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); - $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { - return $env->is_runtime && ! in_array($env->key, $previewKeys); - }); - foreach ($fallback_production_vars as $env) { - if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { - continue; - } - - $envs->push($this->runtimeEnvironmentAssignment($env)); - } - } - // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists - if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } } @@ -2250,6 +2234,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } else { $sorted_environment_variables = $this->application->environment_variables_preview() + ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') @@ -2468,6 +2453,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info'); } + /** + * IDs of preview variables that have the same value as the production variable with the same key. + * Coolify copies each new production variable to the previews. With public PR deployments, a + * preview can run code from a fork, so it gets only the preview values that the user changed. + * + * @return array + */ + private function previewVariableIdsWithProductionValues(): array + { + if ($this->previewVariableIdsWithProductionValues !== null) { + return $this->previewVariableIdsWithProductionValues; + } + + if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled) { + return $this->previewVariableIdsWithProductionValues = []; + } + + $productionValues = $this->application->environment_variables()->get()->pluck('value', 'key'); + + return $this->previewVariableIdsWithProductionValues = $this->application->environment_variables_preview()->get() + ->filter(fn (EnvironmentVariable $env) => $productionValues->has($env->key) && $productionValues->get($env->key) === $env->value) + ->pluck('id') + ->all(); + } + + /** + * The preview variables that this preview deployment uses. + */ + private function deployedPreviewVariables(): Collection + { + return $this->application->environment_variables_preview->whereNotIn('id', $this->previewVariableIdsWithProductionValues()); + } + /** * Skip build-time variables with names that new variables can no longer use (such as my-var). * validateDeploymentEnvironmentVariableKeys() logs a warning for them before the deployment starts. @@ -3520,7 +3538,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } else { - foreach ($this->application->nixpacks_environment_variables_preview as $env) { + foreach ($this->application->nixpacks_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get() as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue) && $resolvedValue !== '') { $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue; @@ -3602,11 +3620,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $genericBuildVariables = $this->pull_request_id === 0 ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get(); + : $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->withoutBuildpackControlVariables()->where('is_buildtime', true)->get(); $railpackVariables = $this->pull_request_id === 0 ? $this->application->railpack_environment_variables()->get() - : $this->application->railpack_environment_variables_preview()->get(); + : $this->application->railpack_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get(); $variables = $this->withoutLegacyEnvironmentVariableKeys($genericBuildVariables->merge($railpackVariables)) ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { @@ -4016,7 +4034,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { - if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'SOURCE_COMMIT')->isEmpty()) { if (! is_null($this->commit)) { $coolify_envs->put('SOURCE_COMMIT', $this->commit); } else { @@ -4024,14 +4042,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } } - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_FQDN')->isEmpty()) { if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_URL', $this->preview->fqdn); } else { $coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn); } } - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_URL')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_URL')->isEmpty()) { $url = $this->hostsFromFqdn($this->preview->fqdn); if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_FQDN', $url); @@ -4040,15 +4058,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_BRANCH')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_BRANCH')->isEmpty()) { $coolify_envs->put('COOLIFY_BRANCH', $local_branch); } - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { $coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid); } // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache if (! $forBuildTime) { - if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); } } @@ -4056,7 +4074,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Runtime only, so a project or server change does not break the Docker build cache if (! $forBuildTime) { - $coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->application->environment_variables_preview->pluck('key'), $this->mainServer)); + $coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->deployedPreviewVariables()->pluck('key'), $this->mainServer)); } } else { @@ -4153,6 +4171,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } else { $envs = $this->application->environment_variables_preview() + ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -5364,6 +5383,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() + ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index a0ceea9598..8a898cd3e8 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -86,7 +86,7 @@
'DB_PASSWORD', + 'value' => 'production-secret', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + $application->environment_variables_preview()->where('key', 'DB_PASSWORD')->update(['is_runtime' => false]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 99, + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->filter(fn (string $env) => str($env)->contains('production-secret')))->toBeEmpty(); +}); + +it('keeps copied production values out of previews when public PR deployments are on', function (string $buildPack) { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => $buildPack, + 'docker_compose_raw' => "services:\n app:\n image: nginx\n", + ]); + $application->settings()->update(['is_pr_deployments_public_enabled' => true]); + + createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret']); + createApplicationEnvironmentVariable($application, ['key' => 'API_URL', 'value' => 'https://api.example.com']); + createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']); + $application->environment_variables_preview()->where('key', 'API_URL')->first() + ->update(['value' => 'https://preview-api.example.com']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'pull_request_id' => 99, + 'remote_secrets_cache' => [], + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); + /** @var Collection $envArgs */ + $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); + /** @var Collection $railpackVariables */ + $railpackVariables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); + + foreach ([$runtimeEnvs, $buildtimeEnvs] as $envLines) { + expect($envLines->filter(fn (string $env) => str($env)->contains('production-secret')))->toBeEmpty() + ->and($envLines->contains(fn (string $env) => str($env)->startsWith('API_URL=') && str($env)->contains('preview-api.example.com')))->toBeTrue(); + } + expect($envArgs->has('DB_PASSWORD'))->toBeFalse() + ->and($envArgs->get('API_URL'))->toBe('https://preview-api.example.com') + ->and($railpackVariables->has('DB_PASSWORD'))->toBeFalse() + ->and($railpackVariables->get('API_URL'))->toBe('https://preview-api.example.com'); + if ($buildPack !== 'dockercompose') { + expect($runtimeEnvs)->toContain('PORT='.$application->main_port()[0]); + } +})->with(['dockerfile', 'dockercompose']); + +it('keeps copied production values for previews when public PR deployments are off', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret']); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 99, + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=')))->toBeTrue(); }); it('keeps buildpack variables read at container start in the runtime env', function (int $pullRequestId, bool $isPreview) { @@ -1720,39 +1791,6 @@ it('writes compose-safe preview runtime env files for user values', function () ->toContain('PREVIEW_QUOTED_VALUE='.escapeComposeEnvFileValue('preview quotes" \'$`')); }); -it('writes compose-safe production fallbacks to preview runtime env files', function () { - [$application, $server] = makeDeploymentControlVarFixture(); - - createApplicationEnvironmentVariable($application, [ - 'key' => 'PREVIEW_TRIGGER', - 'value' => 'preview-value', - 'is_preview' => true, - 'is_literal' => false, - 'is_runtime' => true, - 'is_buildtime' => false, - ]); - createApplicationEnvironmentVariable($application, [ - 'key' => 'FALLBACK_QUOTED_VALUE', - 'value' => 'fallback quotes" \'$`', - 'is_literal' => true, - 'is_runtime' => true, - 'is_buildtime' => false, - ]); - $application->environment_variables_preview() - ->where('key', 'FALLBACK_QUOTED_VALUE') - ->delete(); - - [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ - 'pull_request_id' => 42, - ]); - - /** @var Collection $runtimeEnvs */ - $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); - - expect($runtimeEnvs) - ->toContain('FALLBACK_QUOTED_VALUE='.escapeComposeEnvFileValue('fallback quotes" \'$`')); -}); - it('writes compose-safe runtime env values for remote secret references', function () { [$application, $server] = makeDeploymentControlVarFixture();