Files
coolify/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
T
Andras BacsaiandClaude Opus 5.5 1e207292b6 fix(deployments): keep secrets out of failed command logs
- A failed command marked skip_command_log (for example the .env,
  build-time env, and SSH key writes) no longer puts its text into the
  exception, which was shown as the visible "Deployment failed" line
  with all secrets as base64. Its error output is also cleaned.
- Mark more secret-carrying commands as sensitive: the helper container
  with build secrets, railpack prepare, the Nixpacks plan, and Compose
  file writes.
- Dev debug lines list only variable names, not values.
- Invalid build-time variable names such as my-var stop a deployment
  only when it builds an image. Docker image deployments log a warning
  with a suggested name instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:40:53 +02:00

1336 lines
52 KiB
PHP

<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => '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')
->toContain(REDACTED);
$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('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(): 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('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('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:
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('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_NAME',
'value' => 'coolify',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'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()
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
->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=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
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();
});
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('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 key before running a deployment 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'))
->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) {
[$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'))
->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');
})->with([
'runtime-only name with =' => ['A=B', false],
'runtime-only name with a newline' => ["A\nB", false],
'build-time name with a hyphen' => ['my-var', true],
]);
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('fails with a clear message for invalid build-time names when the deployment builds an image', 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]);
expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys'))
->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment: my-var');
expect(collect($job->recordedLogEntries)->implode("\n"))
->toContain('Build-time variable names must start with a letter or underscore');
})->with(['dockerfile', 'nixpacks', 'static', 'railpack', 'dockercompose']);
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('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();
});