'APP_SECRET', 'value' => 'sensitive-value', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'configuration_dir' => '/data/coolify/applications/test-app', 'remote_secrets_cache' => [], 'saved_outputs' => [ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", ], ]); invokeDeploymentJobMethod($job, $reflection, 'save_runtime_environment_variables'); invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables'); invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); $writeCommands = collect($job->recordedCommands) ->flatMap(fn (array $commands): array => $commands) ->filter(function (mixed $command): bool { if (! is_array($command)) { return false; } $commandString = $command['command'] ?? $command[0] ?? null; return is_string($commandString) && str_contains($commandString, 'base64 -d | tee'); }) ->values(); expect($writeCommands)->toHaveCount(4) ->each->toHaveKey('skip_command_log', true); }); it('redacts resolved remote secrets from command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'], ]); expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value')) ->toBe('token='.REDACTED); }); it('does not retain locked values from generated build-time debug logs', function () { config()->set('app.env', 'local'); [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'SINGLE_MARKER', 'value' => 'harmless-single-marker', 'is_shown_once' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'MULTILINE_MARKER', 'value' => "harmless-first-marker\nharmless-second-marker", 'is_multiline' => true, 'is_shown_once' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'harmless-debug-log-deployment', 'application_id' => $application->id, 'server_id' => $server->id, ]); foreach ($job->recordedLogEntries as $entry) { $deployment->addLogEntry($entry); } $retainedLogs = $deployment->fresh()->logs; expect($retainedLogs) ->not->toContain('harmless-single-marker') ->not->toContain('harmless-first-marker') ->not->toContain('harmless-second-marker'); $member = User::factory()->create(); $application->team()->members()->attach($member->id, ['role' => 'member']); $application->settings->update(['is_debug_enabled' => true]); $this->actingAs($member); $visibleLines = decode_remote_command_output($deployment->fresh())->pluck('line')->implode("\n"); expect($visibleLines) ->toContain('[DEBUG]') ->not->toContain('harmless-first-marker') ->not->toContain('harmless-second-marker'); }); it('does not print build-time values in the development debug lines', function () { config()->set('app.env', 'local'); [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'normal-secret-marker']); createApplicationEnvironmentVariable($application, ['key' => 'LITERAL_TOKEN', 'value' => 'literal-secret-marker', 'is_literal' => true]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); $debugLines = collect($job->recordedLogEntries)->filter(fn (string $line) => str_contains($line, '[DEBUG]'))->implode("\n"); expect($debugLines) ->toContain('[DEBUG] Build-time env: DB_PASSWORD') ->toContain('[DEBUG] Build-time env: LITERAL_TOKEN') ->not->toContain('normal-secret-marker') ->not->toContain('literal-secret-marker'); }); it('redacts generated multiline forms in remote command and output logging', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'MULTILINE_MARKER', 'value' => "harmless-first-marker\nharmless-second-marker", 'is_multiline' => true, 'is_shown_once' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); $generated = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables') ->first(fn (string $line): bool => str_starts_with($line, 'MULTILINE_MARKER=')); foreach ([$generated, str_replace("\n", '\\n', $generated), 'harmless-second-marker'] as $loggedForm) { $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'output: '.$loggedForm); expect($redacted) ->not->toContain('harmless-first-marker') ->not->toContain('harmless-second-marker') ->toContain('output: '); } }); it('keeps deployment logging available if value formatting fails', function () { [$application, $server] = makeDeploymentControlVarFixture(); $variable = new class extends EnvironmentVariable { public function logRedactionValues(?Collection $sharedVariables = null): array { throw new RuntimeException('Harmless formatting failure'); } }; $variable->is_shown_once = true; $application->setRelation('environment_variables', collect([$variable])); $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'harmless-formatting-failure', 'application_id' => $application->id, 'server_id' => $server->id, ]); $deployment->setRelation('application', $application); $deployment->addLogEntry('Harmless log text'); expect(json_decode($deployment->fresh()->logs, true)[0]['output'])->toBe(REDACTED); [$job, $reflection] = makeControlVarFilteringJob($application, $server); readDeploymentJobProperty($job, $reflection, 'application') ->setRelation('environment_variables', collect([$variable])); expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'Harmless command text')) ->toBe(REDACTED); }); it('redacts shell-escaped remote secrets from command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => ['DB_PASS' => "s3cr'et-value"], ]); $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', "docker build --build-arg DB_PASS='s3cr'\\''et-value' --env 'DB_PASS=s3cr'\\''et-value' ."); expect($redacted)->not->toContain('s3cr') ->toContain('--build-arg DB_PASS='.REDACTED); }); it('ignores empty and non-string remote secrets when redacting command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => [ 'EMPTY_SECRET' => '', 'NULL_SECRET' => null, 'NUMERIC_SECRET' => 123, 'API_TOKEN' => 'remote-secret-value', ], ]); expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'id=123 token=remote-secret-value')) ->toBe('id=123 token='.REDACTED); }); class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob { public array $recordedCommands = []; public array $recordedLogEntries = []; public array $writtenArtifacts = []; public ?string $writtenDockerfile = null; public function __construct() {} public function execute_remote_command(...$commands) { $this->recordedCommands[] = $commands; foreach ($commands as $command) { $commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command; if (! is_string($commandString)) { continue; } if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) { $this->writtenDockerfile = base64_decode($matches[1]) ?: null; } if (preg_match('~echo .*?([A-Za-z0-9+/=]{8,}).*?\\| base64 -d \\| tee (/artifacts/[^ ]+) > /dev/null~', $commandString, $matches) === 1) { $this->writtenArtifacts[$matches[2]] = base64_decode($matches[1]); } } } } function makeDeploymentControlVarFixture(array $applicationAttributes = []): array { $team = Team::create([ 'name' => 'Control Var Team', 'description' => 'Team for deployment control var tests.', 'personal_team' => false, 'show_boarding' => false, ]); $project = Project::create([ 'name' => 'Control Var Project', 'team_id' => $team->id, ]); $environment = Environment::where('project_id', $project->id)->firstOrFail(); $server = Server::factory()->create([ 'team_id' => $team->id, ]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'build_pack' => 'dockerfile', ...$applicationAttributes, ]); $application->settings()->update([ 'inject_build_args_to_dockerfile' => true, 'include_source_commit_in_build' => false, 'is_env_sorting_enabled' => false, ]); return [$application->fresh(), $server]; } function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable { return EnvironmentVariable::create([ 'resourceable_type' => Application::class, 'resourceable_id' => $application->id, 'is_preview' => false, 'is_runtime' => true, 'is_buildtime' => true, 'is_multiline' => false, 'is_literal' => false, ...$attributes, ]); } function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array { $job = new TestableControlVarFilteringDeploymentJob; $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $queue = Mockery::mock(ApplicationDeploymentQueue::class); $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use ($job) { $job->recordedLogEntries[] = $message; return null; }); $properties = [ 'application' => $application->fresh(), 'application_deployment_queue' => $queue, 'build_pack' => $application->build_pack, 'mainServer' => $server, 'pull_request_id' => 0, 'commit' => 'HEAD', 'basedir' => '/artifacts/test-app', 'workdir' => '/artifacts/test-app', 'deployment_uuid' => 'deployment-uuid', 'dockerfile_location' => '/Dockerfile', 'container_name' => 'control-var-app', 'coolify_variables' => null, 'dockerSecretsSupported' => false, ]; $mergedProperties = array_merge($properties, $overrides); $mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []); if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) { $mergedProperties['preview'] = ApplicationPreview::create([ 'application_id' => $application->id, 'pull_request_id' => $mergedProperties['pull_request_id'], 'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'], 'fqdn' => 'https://preview.example.com', ]); } foreach ($mergedProperties as $property => $value) { $reflectionProperty = $reflection->getProperty($property); $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($job, $value); } return [$job, $reflection]; } function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed { $reflectionMethod = $reflection->getMethod($method); $reflectionMethod->setAccessible(true); return $reflectionMethod->invoke($job, ...$arguments); } function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed { $reflectionProperty = $reflection->getProperty($property); $reflectionProperty->setAccessible(true); return $reflectionProperty->getValue($job); } it('filters buildpack control vars from generic build args', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_NODE_VERSION', 'value' => '22', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_NODE_VERSION', 'value' => '20', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); /** @var Collection $envArgs */ $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); expect($envArgs->get('APP_ENV'))->toBe('production'); expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse(); }); it('filters buildpack control vars from preview build-time env files', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_preview' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_NODE_VERSION', 'value' => '22', 'is_preview' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_NODE_VERSION', 'value' => '20', 'is_preview' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => 42, ]); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue(); expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); }); it('rejects unsafe Nixpacks plan variable keys before writing the build-time env file', function (string $key) { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ $key => 'value', ], ]), ]); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')) ->toThrow(DeploymentException::class); })->with([ 'command substitution' => 'X$(id)', 'backticks' => 'X`id`', 'newline' => "X\nid", 'shell assignment' => 'X=value', 'semicolon' => 'X;id', 'pipe' => 'X|id', 'ampersand' => 'X&id', 'leading dollar' => '$(id)', 'command substitution with arguments' => 'X$(docker run --rm -v /:/mnt alpine true)', ]); it('keeps persisted dotted user build-time variable keys', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'X.VALUE', 'value' => 'unsafe', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); expect($buildtimeEnvs)->toContain('X.VALUE="unsafe"'); }); it('loads shell variables and passes dotted variables through the build-time environment launcher', function () { $temporaryDirectory = sys_get_temp_dir().'/coolify-build-env-'.str()->random(8); expect(mkdir($temporaryDirectory))->toBeTrue(); $shellEnvironmentPath = $temporaryDirectory.'/build-time-shell.env'; $launcherPath = $temporaryDirectory.'/run-with-build-time-env'; $injectionMarkerPath = $temporaryDirectory.'/injection-marker'; try { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'BASE_VALUE', 'value' => 'expanded', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'X.VALUE', 'value' => '$BASE_VALUE', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'DOTTED.VALUE', 'value' => "$(touch {$injectionMarkerPath})", ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables'); expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH]) ->toContain('BASE_VALUE="expanded"') ->toContain('X.VALUE="$BASE_VALUE"'); expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH]) ->toContain('BASE_VALUE="expanded"') ->not->toContain('X.VALUE'); expect($job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH]) ->toContain('source '.ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH) ->toContain('X.VALUE="$BASE_VALUE"') ->toContain('exec env'); $wrappedCommand = invokeDeploymentJobMethod($job, $reflection, 'wrap_build_command_with_env_export', 'printenv X.VALUE'); expect($wrappedCommand) ->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH) ->toContain("/bin/bash -c 'printenv X.VALUE'") ->not->toContain('source '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH); file_put_contents($shellEnvironmentPath, $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH]); file_put_contents( $launcherPath, str_replace( 'source '.ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH, 'source '.$shellEnvironmentPath, $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH], ), ); chmod($launcherPath, 0700); $process = new Process(['/bin/bash', $launcherPath, '/bin/bash', '-c', 'printenv X.VALUE; printenv DOTTED.VALUE']); $process->mustRun(); expect($process->getOutput()) ->toContain("expanded\n") ->toContain("$(touch {$injectionMarkerPath})"); expect(file_exists($injectionMarkerPath))->toBeFalse(); } finally { @unlink($launcherPath); @unlink($shellEnvironmentPath); @unlink($injectionMarkerPath); @rmdir($temporaryDirectory); } }); it('keeps the original sourced environment path when build-time keys are shell safe', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_VALUE', 'value' => 'safe', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables'); expect($job->writtenArtifacts) ->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_ENV_PATH) ->not->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_SHELL_ENV_PATH) ->not->toHaveKey(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH); $wrappedCommand = invokeDeploymentJobMethod($job, $reflection, 'wrap_build_command_with_env_export', 'docker build .'); expect($wrappedCommand) ->toContain('set -a && source '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH.' && set +a && docker build .') ->not->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH); }); it('skips dotted keys from the Nixpacks plan without failing the deployment', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'spring.profiles.active', 'value' => 'prod', ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'saved_outputs' => collect([ 'nixpacks_plan' => json_encode([ 'variables' => [ 'X.VALUE' => 'dotted-from-toml', 'SAFE_FROM_TOML' => 'ok-from-toml', 'NIXPACKS_NODE_VERSION' => '22', ], ]), 'nixpacks_type' => 'node', ]), ]); invokeDeploymentJobMethod($job, $reflection, 'generate_nixpacks_confs'); $variables = collect(readDeploymentJobProperty($job, $reflection, 'nixpacks_plan_json')->get('variables')); expect($variables->has('SAFE_FROM_TOML'))->toBeTrue(); expect($variables->has('X.VALUE'))->toBeFalse(); expect($variables->has('spring.profiles.active'))->toBeFalse(); $logs = implode("\n", $job->recordedLogEntries); expect($logs) ->toContain('Build-time variable X.VALUE has a dot in its name, which Nixpacks cannot pass to the build') ->toContain('Build-time variable spring.profiles.active has a dot in its name') ->toContain('Suggested name: X_VALUE') ->toContain('Available at Buildtime') ->not->toContain('dotted-from-toml') ->not->toContain('prod'); }); it('still allows underscore Nixpacks plan keys through generate_build_env_variables', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ 'SAFE_FROM_TOML' => 'ok-from-toml', 'NIXPACKS_NODE_VERSION' => '22', ], ]), ]); invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables'); expect(readDeploymentJobProperty($job, $reflection, 'dockerSecretsSupported'))->toBeFalse(); }); it('mounts underscore nixpacks secrets on RUN lines when build secrets are enabled', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'env_args' => collect(['SAFE_FROM_TOML' => 'ok-from-toml']), 'nixpacks_plan_json' => collect([ 'variables' => ['SAFE_FROM_TOML' => 'ok-from-toml'], ]), 'build_secrets' => '--secret id=SAFE_FROM_TOML,env=SAFE_FROM_TOML', 'saved_outputs' => collect([ 'dockerfile_content' => "FROM alpine\nARG SAFE_FROM_TOML\nRUN printenv SAFE_FROM_TOML", ]), ]); invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile'); expect($job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile']) ->toContain('RUN --mount=type=secret,id=SAFE_FROM_TOML,env=SAFE_FROM_TOML') ->toContain('printenv SAFE_FROM_TOML'); }); it('skips unsafe reserved Nixpacks plan variable keys before validation', function (string $key) { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ $key => 'value', ], ]), ]); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith($key.'=')))->toBeFalse(); })->with([ 'Coolify key' => 'COOLIFY_$(id)', 'service key' => 'SERVICE_$(id)', ]); it('explains invalid Nixpacks plan variable keys in deployment logs', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ 'XPACK;SECURITY;ENABLED' => 'true', ], ]), ]); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')) ->toThrow(DeploymentException::class, 'Invalid environment variable name from the Nixpacks plan: XPACK;SECURITY;ENABLED'); $logs = implode("\n", $job->recordedLogEntries); expect($logs) ->toContain('Invalid environment variable name from the Nixpacks plan: XPACK;SECURITY;ENABLED') ->toContain('must start with a letter or underscore') ->toContain('How to fix') ->toContain('nixpacks.toml') ->toContain('https://nixpacks.com/docs/configuration/file'); }); it('marks an invalid variable name failure as already explained in the deployment log', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ 'XPACK;SECURITY;ENABLED' => 'true', ], ]), ]); try { invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); $this->fail('The deployment did not stop.'); } catch (DeploymentException $exception) { expect($exception->isMessageAlreadyLogged())->toBeTrue(); } }); it('truncates long Nixpacks plan variable keys in deployment logs', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); $key = 'X$(docker run --rm alpine sh -c "'.str_repeat('a', 200).'TAIL_SHOULD_BE_TRUNCATED")'; [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ $key => 'x', ], ]), ]); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')) ->toThrow(DeploymentException::class); $logs = implode("\n", $job->recordedLogEntries); expect($logs) ->toContain('Invalid environment variable name from the Nixpacks plan: X$(docker run --rm') ->toContain('...') ->not->toContain('TAIL_SHOULD_BE_TRUNCATED') ->toContain('nixpacks.toml'); }); it('bounds every deployment log entry for long invalid Nixpacks variable keys', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); $key = 'X'.str_repeat('$', 10_000); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ $key => 'x', ], ]), ]); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')) ->toThrow(DeploymentException::class); $longestLogEntryLength = max(array_map(strlen(...), $job->recordedLogEntries)); expect($longestLogEntryLength)->toBeLessThanOrEqual(200); }); it('keeps shell-safe Nixpacks plan variables in the build-time env file', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'nixpacks_plan_json' => collect([ 'variables' => [ 'APP_NAME' => 'coolify', '_PRIVATE_VALUE' => 'secret', 'X.VALUE' => 'dotted', ], ]), ]); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); expect($buildtimeEnvs)->toContain("APP_NAME='coolify'") ->toContain("_PRIVATE_VALUE='secret'") ->toContain("X.VALUE='dotted'"); }); it('does not let preview docker compose service names override generated build-time service names', function () { $compose = <<<'YAML' services: app: image: nginx postgresapp: image: postgres:16-alpine YAML; [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => $compose, 'docker_compose' => $compose, 'docker_compose_domains' => '[]', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'SERVICE_NAME_POSTGRESAPP', 'value' => '', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'SERVICE_URL_APP', 'value' => '', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => 241, ]); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); $envString = $buildtimeEnvs->implode("\n"); expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'"); expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""'); expect($envString)->not->toContain('SERVICE_URL_APP='); }); it('does not let production docker compose service names override generated build-time service names', function () { $compose = <<<'YAML' services: app: image: nginx postgresapp: image: postgres:16-alpine YAML; [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => $compose, 'docker_compose' => $compose, 'docker_compose_domains' => '[]', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'SERVICE_NAME_POSTGRESAPP', 'value' => 'stale-postgresapp', 'is_runtime' => true, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); $envString = $buildtimeEnvs->implode("\n"); expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'"); expect($envString)->not->toContain('stale-postgresapp'); }); it('filters docker compose generated service variables from build args', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'SERVICE_NAME_POSTGRESAPP', 'value' => '', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'SERVICE_URL_APP', 'value' => 'https://preview.example.com', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => 241, ]); invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); /** @var Collection $envArgs */ $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); expect($envArgs->get('APP_ENV'))->toBe('production'); expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse(); expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse(); }); it('does not fall back to production vars when preview copies are deleted', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_NAME', 'value' => 'coolify', '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', 'APP_NAME')->delete(); [$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('APP_NAME=')))->toBeFalse(); expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue(); }); it('does not give production runtime values to a preview copy that is not a runtime variable', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n app:\n image: nginx\n", ]); createApplicationEnvironmentVariable($application, [ 'key' => '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) { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']); foreach ([ 'APP_NAME' => 'coolify', 'NIXPACKS_PHP_ROOT_DIR' => '/app/public', 'NIXPACKS_PHP_FALLBACK_PATH' => '/index.php', 'NIXPACKS_SPA_OUTPUT_DIR' => 'dist', 'RAILPACK_SKIP_MIGRATIONS' => 'true', 'NIXPACKS_NODE_VERSION' => '22', 'RAILPACK_NODE_VERSION' => '20', ] as $key => $value) { createApplicationEnvironmentVariable($application, [ 'key' => $key, 'value' => $value, 'is_preview' => $isPreview, ]); } [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => $pullRequestId, ]); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs)->toContain( 'APP_NAME='.escapeComposeEnvFileValue('coolify'), 'NIXPACKS_PHP_ROOT_DIR='.escapeComposeEnvFileValue('/app/public'), 'NIXPACKS_PHP_FALLBACK_PATH='.escapeComposeEnvFileValue('/index.php'), 'NIXPACKS_SPA_OUTPUT_DIR='.escapeComposeEnvFileValue('dist'), 'RAILPACK_SKIP_MIGRATIONS='.escapeComposeEnvFileValue('true'), ); expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); })->with([ 'production' => [0, false], 'preview' => [42, true], ]); it('keeps buildpack variables read at container start out of generic build args', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_PHP_ROOT_DIR', 'value' => '/app/public', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_SKIP_MIGRATIONS', 'value' => 'true', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); /** @var Collection $envArgs */ $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); expect($envArgs->has('NIXPACKS_PHP_ROOT_DIR'))->toBeFalse(); expect($envArgs->has('RAILPACK_SKIP_MIGRATIONS'))->toBeFalse(); }); it('creates missing Laravel defaults as preview variables for preview deployments', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']); $keys = ['NIXPACKS_PHP_ROOT_DIR', 'NIXPACKS_PHP_FALLBACK_PATH']; foreach (array_combine($keys, ['/app/web', '/app.php']) as $key => $value) { createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value]); } $application->environment_variables_preview()->whereIn('key', $keys)->delete(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => 42]); [$fallbackPath, $rootDir] = invokeDeploymentJobMethod($job, $reflection, 'laravel_finetunes'); expect($fallbackPath->is_preview)->toBeTrue() ->and($fallbackPath->value)->toBe('/index.php') ->and($rootDir->is_preview)->toBeTrue() ->and($rootDir->value)->toBe('/app/public') ->and($application->environment_variables()->whereIn('key', $keys)->pluck('value', 'key')->all())->toBe([ 'NIXPACKS_PHP_ROOT_DIR' => '/app/web', 'NIXPACKS_PHP_FALLBACK_PATH' => '/app.php', ]) ->and($application->environment_variables_preview()->whereIn('key', $keys)->count())->toBe(2); }); it('filters buildpack control vars from dockerfile arg injection', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_NODE_VERSION', 'value' => '22', 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_NODE_VERSION', 'value' => '20', 'is_runtime' => false, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => [], 'saved_outputs' => [ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", ], ]); invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); expect($job->writtenDockerfile)->toContain('ARG APP_ENV'); expect($job->writtenDockerfile)->toContain('ARG COOLIFY_BUILD_SECRETS_HASH='); expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION='); expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION='); }); it('passes dotted keys as Docker build secrets for Dockerfile builds', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockerfile', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'dockerSecretsSupported' => true, ]); invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect([ 'DOTTED.USER' => 'df-dotted', 'SAFE' => 'ok', ])); expect(readDeploymentJobProperty($job, $reflection, 'build_secrets')) ->toContain('id=DOTTED.USER,env=DOTTED.USER') ->toContain('id=SAFE,env=SAFE'); }); it('keeps dotted dockerfile build args when secrets are not used', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'DOTTED.USER', 'value' => 'df-dotted', ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); /** @var Collection $envArgs */ $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); expect($envArgs->get('DOTTED.USER'))->toBe('df-dotted'); }); it('does not write environment values into generated Dockerfile ARG declarations', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => "value\nRUN touch /tmp/injected", 'is_runtime' => false, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => [], 'saved_outputs' => ['dockerfile' => 'FROM alpine'], ]); invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); expect($job->writtenDockerfile) ->toContain('ARG SAFE_KEY') ->not->toContain('RUN touch /tmp/injected') ->not->toContain('value'); }); it('rejects unsafe keys before generating Railpack secret flags', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server); expect(fn () => invokeDeploymentJobMethod( $job, $reflection, 'railpack_build_secret_flags', collect(['BAD$(id)' => 'x']), ))->toThrow(DeploymentException::class, 'Invalid environment variable name from the Railpack environment'); expect($job->recordedCommands)->toBeEmpty(); }); it('rejects unsafe legacy keys before generating BuildKit secret flags', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server); expect(fn () => invokeDeploymentJobMethod( $job, $reflection, 'generate_build_secrets', collect(['BAD$(id)' => 'secret']), ))->toThrow(DeploymentException::class, 'Invalid environment variable name from the build secret environment'); expect($job->recordedCommands)->toBeEmpty(); }); it('rejects an unsafe stored build-time key before running a build command', function () { [$application, $server] = makeDeploymentControlVarFixture(); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'BAD$(id)']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', true)) ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment'); expect($job->recordedCommands)->toBeEmpty(); }); it('keeps deploying existing runtime-only variables whose names new variables cannot use', function () { [$application, $server] = makeDeploymentControlVarFixture(); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => false, ]); // Names like my-var were accepted before the current rules; the model no longer allows them. DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); expect(collect($job->recordedLogEntries)->implode("\n")) ->toContain('my-var') ->toContain('Suggested name: my_var'); expect($job->recordedCommands)->toBeEmpty(); }); it('rejects existing variable names that would break the .env file or build commands', function (string $key, bool $isBuildtime, bool $buildsImage) { [$application, $server] = makeDeploymentControlVarFixture(); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => $isBuildtime, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => $key]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', $buildsImage)) ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment'); })->with([ 'runtime-only name with =' => ['A=B', false, false], 'runtime-only name with a newline' => ["A\nB", false, false], 'build-time name with shell characters' => ['my;var', true, true], ]); it('does not fail restarts or deployments that reuse an image on invalid build-time names', function (string $buildPack) { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => $buildPack]); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => true, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'MY-VAR']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => $buildPack]); // handle() runs this check before it is known whether the deployment builds an image. invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); expect(collect($job->recordedLogEntries)->implode("\n")) ->toContain('MY-VAR') ->toContain('Suggested name: MY_VAR') ->not->toContain('Invalid environment variable name'); expect($job->recordedCommands)->toBeEmpty(); })->with(['dockerfile', 'nixpacks', 'static', 'railpack', 'dockercompose']); it('keeps invalid build-time names out of the helper container so restarts with build secrets work', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'nixpacks']); $application->settings()->update(['use_build_secrets' => true]); createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'safe-build-value', 'is_buildtime' => true, ]); $invalidVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'OTHER_KEY', 'value' => 'invalid-build-value', 'is_buildtime' => true, ]); DB::table('environment_variables')->where('id', $invalidVariable->id)->update(['key' => 'MY-VAR']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'nixpacks']); $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'); expect($envFlags) ->toContain("-e 'SAFE_KEY=safe-build-value'") ->not->toContain('MY-VAR') ->not->toContain('invalid-build-value'); }); it('warns instead of failing for invalid build-time names when the deployment does not build an image', function (bool $isRuntime) { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => true, 'is_runtime' => $isRuntime, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); expect(collect($job->recordedLogEntries)->implode("\n")) ->toContain('my-var') ->toContain('Suggested name: my_var') ->not->toContain('Invalid environment variable name'); expect($job->recordedCommands)->toBeEmpty(); })->with([ 'build-time only' => [false], 'build-time and runtime' => [true], ]); it('warns instead of failing for invalid build-time preview names of Docker image deployments', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => true, 'is_preview' => true, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 7]); invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); expect(collect($job->recordedLogEntries)->implode("\n")) ->toContain('Suggested name: my_var') ->not->toContain('Invalid environment variable name'); }); it('still rejects Docker image variable names that would break the .env file', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'secret', 'is_buildtime' => true, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'A=B']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys')) ->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment'); }); it('skips existing build-time variables whose names new variables cannot use', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockerfile', ]); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'legacy-value', 'is_buildtime' => true, 'is_runtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'VALID_KEY', 'value' => 'valid-value', 'is_buildtime' => true, 'is_runtime' => true, ]); // Names like my-var were accepted before the current rules; the model no longer allows them. DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'); expect(collect($job->recordedLogEntries)->implode("\n")) ->toContain('Build-time variable my-var uses a name that new variables cannot use. It is skipped during the build, but is still passed to the container.') ->toContain('Suggested name: my_var') ->not->toContain('legacy-value'); /** @var Collection $buildtimeEnvs */ $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeFalse(); expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'VALID_KEY=')))->toBeTrue(); invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); expect($envArgs->has('my-var'))->toBeFalse(); expect($envArgs->get('VALID_KEY'))->not->toBeNull(); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeTrue(); }); it('skips existing build-time variables whose names new variables cannot use from Railpack builds', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'railpack', ]); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'legacy-value', 'is_buildtime' => true, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'build_pack' => 'railpack', 'branch' => 'main', ]); /** @var Collection $variables */ $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); expect($variables->has('my-var'))->toBeFalse(); }); it('does not pass build-time variables to the helper container of Docker image deployments', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']); $application->settings()->update(['use_build_secrets' => true]); $environmentVariable = createApplicationEnvironmentVariable($application, [ 'key' => 'SAFE_KEY', 'value' => 'harmless-build-value', 'is_buildtime' => true, ]); DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe(''); }); it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'SECRET_TOKEN', 'value' => '{{vault.API_TOKEN}}', 'is_preview' => $isPreview, 'is_runtime' => false, 'is_buildtime' => true, ]); $secret = "secret\$value'quoted"; $escapedSecret = escapeBashEnvValue($secret); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => $pullRequestId, 'remote_secrets_cache' => ['API_TOKEN' => $secret], 'saved_outputs' => [ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", ], ]); invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); $expectedHash = invokeDeploymentJobMethod( $job, $reflection, 'generate_secrets_hash', collect(['SECRET_TOKEN' => $escapedSecret]), ); expect($job->writtenDockerfile) ->toContain('ARG SECRET_TOKEN') ->not->toContain($secret) ->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}") ->not->toContain('$$'); })->with([ 'production' => [0, false], 'preview' => [99, true], ]); it('injects Dockerfile args for plain build-time variables without a secret manager source', function (int $pullRequestId, bool $isPreview) { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_preview' => $isPreview, 'is_runtime' => false, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'pull_request_id' => $pullRequestId, 'saved_outputs' => [ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", ], ]); invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); $expectedHash = invokeDeploymentJobMethod( $job, $reflection, 'generate_secrets_hash', collect(['APP_ENV' => escapeBashEnvValue('production')]), ); expect($job->writtenDockerfile) ->toContain('ARG APP_ENV') ->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}"); expect(readDeploymentJobProperty($job, $reflection, 'remote_secrets_cache'))->toBeNull(); })->with([ 'production' => [0, false], 'preview' => [99, true], ]); it('checks compose Dockerfiles with a portable command that skips missing files', function (bool $dockerfileExists) { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); $workdir = sys_get_temp_dir().'/coolify-compose-dockerfile-'.str()->random(8); expect(mkdir($workdir))->toBeTrue(); if ($dockerfileExists) { file_put_contents($workdir.'/Dockerfile', "FROM alpine\n"); } try { [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'basedir' => $workdir, 'workdir' => $workdir, 'env_args' => collect(['APP_ENV' => 'production']), ]); invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfiles_for_compose', [ 'services' => ['api' => ['build' => ['context' => '.', 'dockerfile' => 'Dockerfile']]], ]); $checkCommand = collect($job->recordedCommands)->flatten(1)->firstWhere('save', 'dockerfile_check_api')[0]; // The helper image ships BusyBox realpath, which accepts no options. expect($checkCommand)->not->toContain('realpath -'); $process = Process::fromShellCommandline(str($checkCommand)->after('docker exec deployment-uuid ')->toString()); $process->run(); expect($process->getExitCode())->toBe(0); expect($process->getOutput())->toBe($dockerfileExists ? realpath($workdir.'/Dockerfile') : ''); } finally { @unlink($workdir.'/Dockerfile'); @rmdir($workdir); } })->with([ 'existing Dockerfile' => [true], 'missing Dockerfile' => [false], ]); it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'railpack', 'fqdn' => 'https://railpack.example.com', 'install_command' => 'pnpm install --frozen-lockfile', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RUNTIME_ONLY', 'value' => 'runtime', 'is_runtime' => true, 'is_buildtime' => false, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_NODE_VERSION', 'value' => '22', 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_NODE_VERSION', 'value' => '20', 'is_runtime' => false, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'build_pack' => 'railpack', 'branch' => 'main', ]); /** @var Collection $variables */ $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); }); it('skips dotted keys from Railpack build variables without failing the deployment', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'railpack', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_ENV', 'value' => 'production', 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'spring.profiles.active', 'value' => 'prod', 'is_runtime' => true, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'build_pack' => 'railpack', 'branch' => 'main', ]); /** @var Collection $variables */ $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->has('spring.profiles.active'))->toBeFalse(); expect(implode("\n", $job->recordedLogEntries)) ->toContain('Build-time variable spring.profiles.active has a dot in its name, which Railpack cannot pass to the build') ->toContain('Suggested name: spring_profiles_active'); }); it('builds preview railpack variables without leaking stale nixpacks vars', function () { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'railpack', 'fqdn' => 'https://railpack.example.com', ]); createApplicationEnvironmentVariable($application, [ 'key' => 'PREVIEW_BUILD_FLAG', 'value' => 'enabled', 'is_preview' => true, 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'PREVIEW_RUNTIME_ONLY', 'value' => 'runtime', 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'NIXPACKS_NODE_VERSION', 'value' => '22', 'is_preview' => true, 'is_runtime' => false, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'RAILPACK_NODE_VERSION', 'value' => '20', 'is_preview' => true, 'is_runtime' => false, 'is_buildtime' => true, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'build_pack' => 'railpack', 'branch' => 'feature/railpack', 'pull_request_id' => 123, ]); /** @var Collection $variables */ $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); }); it('writes compose-safe runtime env files for user values', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'QUOTED_VALUE', 'value' => 'hello world; quotes" \'$`', 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'EXPAND_ME', 'value' => '$BOTH_PHASES', 'is_literal' => false, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'BOTH_PHASES', 'value' => 'both-phases-value', 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'INJECT_CMD', 'value' => '$(touch /tmp/coolify-value-injection)', 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => true, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'EDGE_QUOTES', 'value' => "'keep these quotes'", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); createApplicationEnvironmentVariable($application, [ 'key' => 'MULTILINE_VALUE', 'value' => "\$BOTH_PHASES\nsecond line", 'is_literal' => false, 'is_multiline' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); [$job, $reflection] = makeControlVarFilteringJob($application, $server); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs) ->toContain('QUOTED_VALUE='.escapeComposeEnvFileValue('hello world; quotes" \'$`')) ->toContain('INJECT_CMD='.escapeComposeEnvFileValue('$(touch /tmp/coolify-value-injection)')) ->toContain('EXPAND_ME='.escapeComposeEnvFileValue('$BOTH_PHASES', allowInterpolation: true)) ->toContain('EDGE_QUOTES='.escapeComposeEnvFileValue("'keep these quotes'")) ->toContain('MULTILINE_VALUE='.escapeComposeEnvFileValue("\$BOTH_PHASES\nsecond line")); }); it('writes compose-safe preview runtime env files for user values', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'PREVIEW_QUOTED_VALUE', 'value' => 'preview quotes" \'$`', 'is_preview' => true, 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'pull_request_id' => 42, ]); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs) ->toContain('PREVIEW_QUOTED_VALUE='.escapeComposeEnvFileValue('preview quotes" \'$`')); }); it('writes compose-safe runtime env values for remote secret references', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'SECRET_TOKEN', 'value' => '{{vault.API_TOKEN}}', 'is_literal' => false, 'is_runtime' => true, 'is_buildtime' => false, ]); $secret = "secret\$value'quoted\""; [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'remote_secrets_cache' => ['API_TOKEN' => $secret], ]); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs)->toContain('SECRET_TOKEN='.escapeComposeEnvFileValue($secret)); }); it('keeps the old runtime env format for variables saved before exact escaping', function () { [$application, $server] = makeDeploymentControlVarFixture(); foreach ([ ['key' => 'LEGACY_PLAIN', 'value' => 'a\'b"c\\d', 'is_literal' => false], ['key' => 'LEGACY_LITERAL', 'value' => 'x$y', 'is_literal' => true], ] as $attributes) { $variable = createApplicationEnvironmentVariable($application, [...$attributes, 'is_runtime' => true, 'is_buildtime' => false]); $variable->uses_legacy_escaping = true; $variable->save(); } createApplicationEnvironmentVariable($application, [ 'key' => 'EXACT_PLAIN', 'value' => 'a\'b"c\\d', 'is_literal' => false, 'is_runtime' => true, 'is_buildtime' => false, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs) ->toContain('LEGACY_PLAIN=a\\\'b\\"c\\\\d') ->toContain("LEGACY_LITERAL='x\$y'") ->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true)); }); it('keeps the old runtime env format for preview variables saved before exact escaping', function () { [$application, $server] = makeDeploymentControlVarFixture(); $variable = createApplicationEnvironmentVariable($application, [ 'key' => 'LEGACY_PREVIEW', 'value' => "array:'self'", 'is_preview' => true, 'is_runtime' => true, 'is_buildtime' => false, ]); $variable->uses_legacy_escaping = true; $variable->save(); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 42]); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs)->toContain("LEGACY_PREVIEW=array:\\'self\\'"); }); it('resolves exact runtime env values without loading each variable resource', function () { [$application, $server] = makeDeploymentControlVarFixture(); foreach (['FIRST_EXACT', 'SECOND_EXACT'] as $key) { createApplicationEnvironmentVariable($application, [ 'key' => $key, 'value' => "array:'self'", 'is_runtime' => true, 'is_buildtime' => false, ]); } [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); DB::enableQueryLog(); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); $applicationLookups = collect(DB::getQueryLog()) ->filter(fn (array $query) => str($query['query'])->contains('from "applications"')); DB::disableQueryLog(); expect($runtimeEnvs)->toContain('FIRST_EXACT='.escapeComposeEnvFileValue("array:'self'", allowInterpolation: true)) ->and($applicationLookups)->toBeEmpty(); }); it('keeps dollars literal in runtime JSON values', function () { [$application, $server] = makeDeploymentControlVarFixture(); createApplicationEnvironmentVariable($application, [ 'key' => 'JSON_CONFIG', 'value' => '{"price":"$5"}', 'is_runtime' => true, 'is_buildtime' => false, ]); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($runtimeEnvs)->toContain('JSON_CONFIG='.escapeComposeEnvFileValue('{"price":"$5"}')); }); it('omits only unset Compose overrides from production and preview runtime files', function (int $pullRequestId) { Server::flushIdentityMap(); [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n app:\n environment:\n INHERITED: \${INHERITED:-info}\n EMPTY: \${EMPTY-default}\n", ]); $application->update(['docker_compose' => $application->docker_compose_raw]); foreach (['INHERITED' => null, 'EMPTY' => ''] as $key => $value) { createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value]); $application->environment_variables_preview()->where('key', $key)->firstOrFail()->update(['value' => $value]); } [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]); $variables = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect($variables->contains(fn (string $line) => str_starts_with($line, 'INHERITED=')))->toBeFalse() ->and($variables->contains('EMPTY='.escapeComposeEnvFileValue('')))->toBeTrue(); })->with([0, 42]); it('includes migrated null Compose overrides in production and preview runtime files', function (int $pullRequestId) { Server::flushIdentityMap(); [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n app:\n environment:\n LEGACY: \${LEGACY:-info}\n", ]); $application->update(['docker_compose' => $application->docker_compose_raw]); createApplicationEnvironmentVariable($application, ['key' => 'LEGACY', 'value' => null]); foreach ([$application->environment_variables(), $application->environment_variables_preview()] as $relation) { $relation->where('key', 'LEGACY')->firstOrFail()->forceFill(['preserve_compose_null' => true])->save(); } [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]); expect(invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables')->contains('LEGACY='.escapeComposeEnvFileValue('')))->toBeTrue(); })->with([0, 42]); it('passes the build secrets hash as a build arg so a changed secret value invalidates the Docker cache', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['dockerSecretsSupported' => true]); invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect(['NEXT_PUBLIC_API_URL' => 'https://old.example.com'])); $oldFlags = readDeploymentJobProperty($job, $reflection, 'build_secrets'); invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com'])); $newFlags = readDeploymentJobProperty($job, $reflection, 'build_secrets'); $hashArg = fn (string $flags): ?string => preg_match("/--build-arg 'COOLIFY_BUILD_SECRETS_HASH=([a-f0-9]{64})'/", $flags, $matches) === 1 ? $matches[1] : null; expect($hashArg($oldFlags))->not->toBeNull() ->and($hashArg($newFlags))->not->toBeNull() ->and($hashArg($newFlags))->not->toBe($hashArg($oldFlags)) ->and($newFlags)->toContain("--secret 'id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL'"); }); it('declares the build secrets hash ARG in every stage when build secrets are used', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'dockerSecretsSupported' => true, 'env_args' => collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com']), 'build_secrets' => '--secret id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL', 'saved_outputs' => collect([ 'dockerfile_content' => "FROM node:22 AS build\nRUN npm run build\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html", ]), ]); invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/Dockerfile'); expect(explode("\n", $job->writtenDockerfile))->toBe([ '# syntax=docker/dockerfile:1', 'FROM node:22 AS build', 'ARG COOLIFY_BUILD_SECRETS_HASH', 'RUN --mount=type=secret,id=NEXT_PUBLIC_API_URL,env=NEXT_PUBLIC_API_URL npm run build', 'FROM nginx:alpine', 'ARG COOLIFY_BUILD_SECRETS_HASH', 'COPY --from=build /app/dist /usr/share/nginx/html', ]); }); it('passes the build secrets hash as a Compose build arg when build secrets are used', function () { [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'dockerSecretsSupported' => true, 'env_args' => collect(['NEXT_PUBLIC_API_URL' => 'https://new.example.com']), ]); $composeFile = invokeDeploymentJobMethod($job, $reflection, 'add_build_secrets_to_compose', [ 'services' => [ 'web' => ['build' => '.'], 'api' => ['build' => ['context' => './api', 'args' => ['NODE_ENV' => 'production']]], 'worker' => ['build' => ['context' => './worker', 'args' => ['NODE_ENV=production']]], 'db' => ['image' => 'postgres:17'], ], ]); $hash = data_get($composeFile, 'services.web.build.args.COOLIFY_BUILD_SECRETS_HASH'); expect($hash)->toMatch('/^[a-f0-9]{64}$/') ->and(data_get($composeFile, 'services.api.build.args'))->toBe(['NODE_ENV' => 'production', 'COOLIFY_BUILD_SECRETS_HASH' => $hash]) ->and(data_get($composeFile, 'services.worker.build.args'))->toBe(['NODE_ENV=production', "COOLIFY_BUILD_SECRETS_HASH={$hash}"]) ->and(data_get($composeFile, 'services.db'))->toBe(['image' => 'postgres:17']); }); it('keeps heredoc bodies unchanged when it adds build secrets to a Dockerfile', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ 'dockerSecretsSupported' => true, 'env_args' => collect(['API_URL' => 'https://new.example.com']), 'build_secrets' => '--secret id=API_URL,env=API_URL', 'saved_outputs' => collect([ 'dockerfile_content' => "FROM python:3.13\nRUN <writtenDockerfile))->toBe([ '# syntax=docker/dockerfile:1', 'FROM python:3.13', 'ARG COOLIFY_BUILD_SECRETS_HASH', 'RUN --mount=type=secret,id=API_URL,env=API_URL < '80']); createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); expect(collect($job->recordedLogEntries)->contains(fn (string $entry) => str_contains($entry, 'PORT environment variable (3000)')))->toBeTrue(); }); it('does not warn about a PORT mismatch when readonly labels are disabled', function () { [$application, $server] = makeDeploymentControlVarFixture(['ports_exposes' => '80']); $application->settings()->update(['is_container_label_readonly_enabled' => false]); createApplicationEnvironmentVariable($application, ['key' => 'PORT', 'value' => '3000']); [$job, $reflection] = makeControlVarFilteringJob($application, $server); invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); 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(); }); it('lets the Compose build interpolate runtime-only variables without passing them to the build', function (int $pullRequestId) { $compose = <<<'YAML' services: app: build: context: . args: MAPPED_ARG: '${RUNTIME_IN_BUILD_ARGS}' LISTED_FROM_ENV: null environment: FERNET_KEY: '${FERNET_KEY:?}' OPTIONAL_REQUIRED: '${OPTIONAL_REQUIRED?}' BUILD_VALUE: '${BUILD_VALUE}' worker: build: context: ./worker args: - LISTED_ARG environment: FERNET_KEY: '${FERNET_KEY:?}' YAML; [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => $compose, 'docker_compose' => $compose, 'docker_compose_domains' => '[]', ]); $application->settings()->update(['inject_build_args_to_dockerfile' => false]); createApplicationEnvironmentVariable($application, ['key' => 'FERNET_KEY', 'value' => 'runtime-fernet-key', 'is_buildtime' => false]); createApplicationEnvironmentVariable($application, ['key' => 'OPTIONAL_REQUIRED', 'value' => '', 'is_buildtime' => false]); createApplicationEnvironmentVariable($application, ['key' => 'RUNTIME_IN_BUILD_ARGS', 'value' => 'runtime-mapped-arg', 'is_buildtime' => false]); createApplicationEnvironmentVariable($application, ['key' => 'LISTED_FROM_ENV', 'value' => 'runtime-null-arg', 'is_buildtime' => false]); createApplicationEnvironmentVariable($application, ['key' => 'LISTED_ARG', 'value' => 'runtime-listed-arg', 'is_buildtime' => false]); createApplicationEnvironmentVariable($application, ['key' => 'BUILD_VALUE', 'value' => 'build-value']); [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'pull_request_id' => $pullRequestId, 'remote_secrets_cache' => [], 'force_rebuild' => false, 'docker_compose_location' => '/docker-compose.yaml', ]); invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables'); $buildEnvironment = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); invokeDeploymentJobMethod($job, $reflection, 'build_docker_compose_images', parseDockerComposeYaml($compose), $buildEnvironment); $buildEnvFile = $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH]; $buildCommand = collect($job->recordedCommands)->flatten()->first(fn (mixed $command): bool => is_string($command) && str_contains($command, ' build --pull')); $buildArgs = readDeploymentJobProperty($job, $reflection, 'build_args')->implode(' '); expect($buildEnvFile)->toContain('FERNET_KEY="runtime-fernet-key"') ->toContain('OPTIONAL_REQUIRED=""') ->toContain('BUILD_VALUE=') ->not->toContain('runtime-mapped-arg') ->not->toContain('runtime-null-arg') ->not->toContain('runtime-listed-arg') ->and($buildCommand)->toContain('--env-file '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH) ->and($buildArgs)->toContain('BUILD_VALUE') ->not->toContain('FERNET_KEY') ->not->toContain('runtime-'); })->with([ 'production' => 0, 'preview' => 42, ]);