diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6d68160ce..88036f519 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -44,6 +44,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env'; + public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env'; + + public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env'; + private const BUILD_SCRIPT_PATH = '/artifacts/build.sh'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; @@ -201,6 +205,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerSecretsSupported = false; + private bool $dockerSecretsAvailable = false; + + private bool $useBuildtimeEnvironmentLauncher = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -418,6 +426,11 @@ public function handle(): void private function detectBuildKitCapabilities(): void { + $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; + $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -468,18 +481,19 @@ private function detectBuildKitCapabilities(): void } } - // If build secrets are enabled and BuildKit is available, verify --secret flag support - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerSecretsSupported = true; - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); - } else { - $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = true; + if ($this->application->settings->use_build_secrets) { + $this->dockerSecretsSupported = true; + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } + } elseif ($this->application->settings->use_build_secrets) { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } @@ -487,6 +501,7 @@ private function detectBuildKitCapabilities(): void $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } @@ -1631,11 +1646,14 @@ private function generate_buildtime_environment_variables() } foreach ($planVariables as $key => $value) { + $key = (string) $key; + // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { continue; } + $key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan'); $escapedValue = escapeBashEnvValue($value); $envs_dict[$key] = $escapedValue; @@ -1823,6 +1841,7 @@ private function generate_buildtime_environment_variables() // Convert dictionary back to collection in KEY=VALUE format $envs = collect([]); foreach ($envs_dict as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment'); $envs->push($key.'='.$value); } @@ -1836,44 +1855,130 @@ private function generate_buildtime_environment_variables() return $envs; } - private function save_buildtime_environment_variables() + private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string { - // Generate build-time environment variables locally - $environment_variables = $this->generate_buildtime_environment_variables(); - - // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images - if ($environment_variables->isNotEmpty()) { - $envs_base64 = base64_encode($environment_variables->implode("\n")); - - $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); - - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), - ] - ); - - if (isDev()) { - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), - 'hidden' => true, - ] - ); + try { + if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException('Invalid build-time environment variable key.'); } - } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { - // For build packs that source the build-time .env file, create an empty file even if there are no build-time variables - // This ensures the file exists when referenced in build commands - $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), - ] + return $key; + } catch (\InvalidArgumentException $exception) { + $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin); + + throw new DeploymentException( + "Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', + previous: $exception, ); } } + private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void + { + $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + $this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr'); + $this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr'); + $this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info'); + + if ($origin === 'the Nixpacks plan') { + $this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + $this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info'); + $this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info'); + } else { + $this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + } + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + } + + private function logSuggestedShellEnvironmentVariableKey(string $key): void + { + $suggestedKey = str_replace('.', '_', $key); + if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) { + return; + } + + $displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey); + + $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); + } + + private function save_buildtime_environment_variables() + { + $environment_variables = $this->generate_buildtime_environment_variables(); + [$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool { + [$key] = explode('=', $environmentVariable, 2); + + return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1; + }); + + if ($dotted_environment_variables->isEmpty()) { + $this->useBuildtimeEnvironmentLauncher = false; + + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + ]); + + if (isDev()) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), + 'hidden' => true, + ]); + } + } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), + ]); + } + + return; + } + + $this->useBuildtimeEnvironmentLauncher = true; + + $launcher = [ + '#!/bin/bash', + 'set -a', + 'source '.self::BUILD_TIME_SHELL_ENV_PATH, + 'set +a', + ]; + + $launcher[] = 'exec env \\'; + foreach ($dotted_environment_variables as $environmentVariable) { + $launcher[] = " {$environmentVariable} \\"; + } + $launcher[] = ' "$@"'; + + $files = [ + self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"), + self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"), + self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n", + ]; + + $this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true); + + foreach ($files as $path => $contents) { + $contents_base64 = base64_encode($contents); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"), + ]); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH), + ]); + } + private function elixir_finetunes() { if ($this->pull_request_id === 0) { @@ -3653,7 +3758,13 @@ private function build_static_image() */ private function wrap_build_command_with_env_export(string $build_command): string { - return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + if (! $this->useBuildtimeEnvironmentLauncher) { + return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + } + + $escapedBuildCommand = escapeBashEnvValue($build_command); + + return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}"; } private function build_image() @@ -4176,6 +4287,21 @@ private function generate_build_env_variables() $this->analyzeBuildTimeVariables($variables); } + $requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks' + && $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.')); + + if ($requiresDottedEnvironmentSecrets) { + if (! $this->dockerSecretsAvailable) { + $dottedKeys = $variables->keys() + ->filter(fn ($key): bool => str_contains((string) $key, '.')) + ->implode(', '); + + throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server."); + } + + $this->dockerSecretsSupported = true; + } + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; @@ -4440,7 +4566,7 @@ private function add_build_env_variables_to_dockerfile() private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount - if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + if (empty($this->build_secrets)) { return; } @@ -4464,18 +4590,51 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $this->generate_env_variables(); } - $variables = $this->env_args; + $variables = $this->application->build_pack === 'nixpacks' + ? collect($this->nixpacks_plan_json->get('variables')) + : $this->env_args; if ($variables->isEmpty()) { return; } + $dottedKeys = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => str_contains($key, '.')); + + if ($dottedKeys->isNotEmpty()) { + $originalDockerfile = $dockerfile; + $dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string { + $trimmedLine = trim($line); + + if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) { + return $line; + } + + [$instruction, $arguments] = explode(' ', $trimmedLine, 2); + $filteredArguments = collect(preg_split('/\s+/', $arguments)) + ->reject(function (string $argument) use ($dottedKeys): bool { + $key = str($argument)->before('=')->toString(); + + return $dottedKeys->contains($key); + }); + + if ($filteredArguments->isEmpty()) { + return null; + } + + return $instruction.' '.$filteredArguments->implode(' '); + })->filter()->values(); + + $modified = $dockerfile->all() !== $originalDockerfile->values()->all(); + } + // Generate mount strings for all secrets $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; - $modified = false; + $modified ??= false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 5c02b8426..cb1a0de62 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -108,6 +108,11 @@ class ValidationPatterns */ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; + /** + * Pattern for environment variable keys written to shell-sourced files. + */ + public const SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_]*\z/u'; + /** * Characters that are valid in some URL positions but unsafe for values * that are later reused in shell assignment contexts. @@ -192,6 +197,43 @@ public static function isValidEnvironmentVariableKey(string $value): bool return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; } + /** + * Make an environment variable key safe to show in deployment logs. + * + * Control characters are escaped and long values are truncated so an + * unexpected key cannot corrupt or overflow the deployment log output. + */ + public static function displayShellEnvironmentVariableKey(string $value, int $maxLength = 80): string + { + $printable = str($value) + ->replace(["\0", "\r", "\n", "\t"], ['\\0', '\\r', '\\n', '\\t']) + ->value(); + + $printable = preg_replace_callback( + '/[\x00-\x1F\x7F]/', + fn (array $matches): string => sprintf('\\x%02X', ord($matches[0])), + $printable, + ); + + if ($printable === '') { + return '(empty)'; + } + + return str($printable)->limit($maxLength)->value(); + } + + /** + * Validate an environment variable key before writing it to a shell-sourced file. + */ + public static function validatedShellEnvironmentVariableKey(string $value): string + { + if (preg_match(self::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) !== 1) { + throw new \InvalidArgumentException('Invalid environment variable name '.self::displayShellEnvironmentVariableKey($value).'. Names must start with a letter or underscore and contain only letters, numbers, and underscores.'); + } + + return $value; + } + /** * Check if a string is a valid S3 bucket name. */ diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 83244c567..87b3407cb 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1,5 +1,6 @@ \\/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]); + } } } } @@ -92,7 +102,11 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $queue = Mockery::mock(ApplicationDeploymentQueue::class); - $queue->shouldReceive('addLogEntry')->andReturnNull(); + $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use ($job) { + $job->recordedLogEntries[] = $message; + + return null; + }); $properties = [ 'application' => $application->fresh(), @@ -130,12 +144,12 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar return [$job, $reflection]; } -function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed +function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed { $reflectionMethod = $reflection->getMethod($method); $reflectionMethod->setAccessible(true); - return $reflectionMethod->invoke($job); + return $reflectionMethod->invoke($job, ...$arguments); } function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed @@ -205,6 +219,345 @@ function readDeploymentJobProperty(object $job, ReflectionClass $reflection, str 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('uses BuildKit secrets for dotted Nixpacks variables instead of invalid Dockerfile expansion', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'nixpacks', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'dockerBuildkitSupported' => true, + 'dockerSecretsAvailable' => true, + 'env_args' => collect(['X.VALUE' => 'dotted-buildtime-ok']), + 'nixpacks_plan_json' => collect([ + 'variables' => ['X.VALUE' => 'dotted-buildtime-ok'], + ]), + 'saved_outputs' => collect([ + 'dockerfile_content' => "FROM alpine\nARG SAFE X.VALUE\nENV SAFE=\$SAFE X.VALUE=\$X.VALUE\nRUN printenv X.VALUE", + ]), + ]); + + invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables'); + + expect(readDeploymentJobProperty($job, $reflection, 'dockerSecretsSupported'))->toBeTrue(); + expect(readDeploymentJobProperty($job, $reflection, 'build_secrets'))->toContain('--secret id=X.VALUE,env=X.VALUE'); + + invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile'); + + $dockerfile = $job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile']; + + expect($dockerfile) + ->not->toContain('ARG X.VALUE') + ->not->toContain('X.VALUE=$X.VALUE') + ->toContain('ARG SAFE') + ->toContain('ENV SAFE=$SAFE') + ->toContain('RUN --mount=type=secret,id=X.VALUE,env=X.VALUE') + ->toContain('printenv X.VALUE'); +}); + +it('rejects dotted Nixpacks variables when Docker build secrets are unavailable', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'nixpacks', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'dockerBuildkitSupported' => true, + 'dockerSecretsAvailable' => false, + 'nixpacks_plan_json' => collect([ + 'variables' => [ + 'X.VALUE' => 'dotted-buildtime-ok', + 'ANOTHER.DOTTED.VALUE' => 'also-dotted', + ], + ]), + ]); + + expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables')) + ->toThrow( + DeploymentException::class, + 'Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: X.VALUE, ANOTHER.DOTTED.VALUE. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.' + ); +}); + +it('writes dotted Nixpacks ARG and ENV removal when the Dockerfile has no run command', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'nixpacks', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'env_args' => collect(['X.VALUE' => 'dotted-buildtime-ok']), + 'nixpacks_plan_json' => collect([ + 'variables' => ['X.VALUE' => 'dotted-buildtime-ok'], + ]), + 'build_secrets' => '--secret id=X.VALUE,env=X.VALUE', + 'saved_outputs' => collect([ + 'dockerfile_content' => "FROM alpine\nARG X.VALUE=default\nENV X.VALUE=\$X.VALUE", + ]), + ]); + + invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile'); + + expect($job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile']) + ->not->toContain('X.VALUE'); +}); + +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('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: diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 0b3252d7e..c0591cb6a 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -161,6 +161,33 @@ 'empty' => '', ]); +it('accepts shell-safe keys for sourced build-time env files', function (string $key) { + expect(ValidationPatterns::validatedShellEnvironmentVariableKey($key))->toBe($key); +})->with([ + 'letters' => 'APP_ENV', + 'leading underscore' => '_TOKEN', + 'digits after first character' => 'NODE_VERSION_20', +]); + +it('rejects keys that bash would interpret when sourcing a build-time env file', function (string $key) { + expect(fn () => ValidationPatterns::validatedShellEnvironmentVariableKey($key)) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'command substitution' => 'X$(id)', + 'dot notation' => 'X.VALUE', + 'command substitution with arguments' => 'X$(docker run --rm -v /:/mnt alpine true)', +]); + +it('makes unsafe environment variable keys safe to show in logs', function () { + expect(ValidationPatterns::displayShellEnvironmentVariableKey('APP_ENV'))->toBe('APP_ENV'); + expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\nid"))->toBe('X\\nid'); + expect(ValidationPatterns::displayShellEnvironmentVariableKey("X\e[2Jid\x7F"))->toBe('X\\x1B[2Jid\\x7F'); + expect(ValidationPatterns::displayShellEnvironmentVariableKey(''))->toBe('(empty)'); + expect(ValidationPatterns::displayShellEnvironmentVariableKey(str_repeat('A', 100))) + ->toEndWith('...') + ->toBe(str_repeat('A', 80).'...'); +}); + it('generates environment variable key rules with correct defaults', function () { $rules = ValidationPatterns::environmentVariableKeyRules();