Validate build-time environment variable names before writing the build .env file (#11575)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e4146a6314
commit
2018e7f329
4 changed files with 625 additions and 44 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -18,6 +20,10 @@ class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
|
|||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public array $recordedLogEntries = [];
|
||||
|
||||
public array $writtenArtifacts = [];
|
||||
|
||||
public ?string $writtenDockerfile = null;
|
||||
|
||||
public function __construct() {}
|
||||
|
|
@ -36,6 +42,10 @@ public function execute_remote_command(...$commands)
|
|||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue