Files
coolify/tests/Feature/DeploymentSensitiveCommandFailureTest.php

611 lines
27 KiB
PHP

<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\IntegrationToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
const SENSITIVE_FAILURE_SECRET = 'harmless-secret-marker-7431';
class SensitiveCommandFailureDeploymentJob extends ApplicationDeploymentJob
{
public function __construct() {}
}
beforeEach(function () {
config()->set('constants.ssh.mux_enabled', false);
config()->set('constants.ssh.max_retries', 1);
});
/**
* @return array{0: SensitiveCommandFailureDeploymentJob, 1: ReflectionClass, 2: ApplicationDeploymentQueue, 3: Application}
*/
function makeSensitiveCommandFailureJob(array $environmentVariables = []): array
{
$user = User::factory()->create();
$team = $user->teams()->first();
$privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
."b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n"
."QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n"
."hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n"
."AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n"
."uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n"
.'-----END OPENSSH PRIVATE KEY-----';
$privateKey = PrivateKey::create([
'name' => 'sensitive-failure-key-'.uniqid(),
'private_key' => $privateKeyContent,
'team_id' => $team->id,
]);
Storage::fake('ssh-keys');
Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'user' => 'root',
]);
$project = Project::create(['name' => 'Sensitive Failure Project', 'team_id' => $team->id]);
$environment = Environment::where('project_id', $project->id)->firstOrFail();
$destination = $server->standaloneDockers()->firstOrFail();
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockerfile',
]);
$application->settings()->update([
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]);
foreach ($environmentVariables as $attributes) {
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,
]);
}
$queue = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'sensitive-failure-deployment',
'application_id' => $application->id,
'server_id' => $server->id,
'status' => 'in_progress',
]);
$job = new SensitiveCommandFailureDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
foreach ([
'application' => $application->fresh(),
'application_deployment_queue' => $queue,
'server' => $server,
'mainServer' => $server,
'build_pack' => 'dockerfile',
'pull_request_id' => 0,
'commit' => 'HEAD',
'basedir' => '/artifacts/sensitive-failure',
'workdir' => '/artifacts/sensitive-failure',
'configuration_dir' => '/data/coolify/applications/sensitive-failure',
'deployment_uuid' => 'sensitive-failure-deployment',
'dockerfile_location' => '/Dockerfile',
'container_name' => 'sensitive-failure-app',
'coolify_variables' => null,
'dockerSecretsSupported' => false,
'saved_outputs' => new Collection,
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection, $queue, $application];
}
function invokeSensitiveFailureJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($job, ...$arguments);
}
function captureDeploymentException(callable $callback): DeploymentException
{
try {
$callback();
} catch (DeploymentException $exception) {
return $exception;
}
throw new RuntimeException('Expected a DeploymentException.');
}
/**
* @return list<string>
*/
function base64PayloadsInCommand(string $command): array
{
preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $command, $matches);
return array_values(array_filter(
$matches[0],
static fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), SENSITIVE_FAILURE_SECRET)
));
}
it('hides the .env write command and its base64 payload when the write fails', function () {
[$job, $reflection, $queue] = makeSensitiveCommandFailureJob([
['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET],
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
$payloads = array_merge($payloads, base64PayloadsInCommand(remoteProcessScript($process)));
return Process::result(errorOutput: 'tee: /artifacts/sensitive-failure/.env: No space left on device', exitCode: 1);
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables'));
expect($payloads)->not->toBeEmpty();
expect($exception->getMessage())
->toContain('Command execution failed (exit code 1): [command hidden because it contains sensitive data]')
->toContain('No space left on device')
->not->toContain(SENSITIVE_FAILURE_SECRET)
->not->toContain('base64 -d');
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
}
$storedLogs = (string) $queue->fresh()->logs;
expect($storedLogs)
->toContain('No space left on device')
->not->toContain(SENSITIVE_FAILURE_SECRET);
foreach ($payloads as $payload) {
expect($storedLogs)->not->toContain($payload);
}
});
it('redacts the sensitive payload when the error output echoes the command', function () {
[$job, $reflection, $queue] = makeSensitiveCommandFailureJob([
['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET],
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
$commandPayloads = base64PayloadsInCommand(remoteProcessScript($process));
$payloads = array_merge($payloads, $commandPayloads);
return Process::result(errorOutput: "bash: line 1: echo '".($commandPayloads[0] ?? '')."': unexpected failure", exitCode: 2);
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables'));
expect($payloads)->not->toBeEmpty();
$storedLogs = (string) $queue->fresh()->logs;
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
expect($storedLogs)->not->toContain($payload);
}
expect($exception->getMessage())->toContain('unexpected failure');
});
it('hides the build-time env write command when the write fails', function () {
[$job, $reflection] = makeSensitiveCommandFailureJob([
['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false],
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
$payloads = array_merge($payloads, base64PayloadsInCommand(remoteProcessScript($process)));
return Process::result(errorOutput: 'tee: /artifacts/build-time.env: No such file or directory', exitCode: 1);
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'save_buildtime_environment_variables'));
expect($payloads)->not->toBeEmpty();
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->toContain('No such file or directory')
->not->toContain(SENSITIVE_FAILURE_SECRET);
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
}
});
it('keeps the command in the error message for failed commands that are not sensitive', function () {
[$job] = makeSensitiveCommandFailureJob();
Process::fake(['*' => Process::result(errorOutput: 'ls: cannot access: No such file or directory', exitCode: 2)]);
$exception = captureDeploymentException(fn () => $job->execute_remote_command([
'command' => 'ls /artifacts/harmless-missing-directory',
'hidden' => true,
]));
expect($exception->getMessage())
->toContain('Command execution failed (exit code 2): ls /artifacts/harmless-missing-directory')
->toContain('No such file or directory')
->not->toContain('[command hidden because it contains sensitive data]');
});
it('explains how to fix a Docker network that Docker Engine reports with an IPv6 gateway prefix', function () {
[$job] = makeSensitiveCommandFailureJob();
Process::fake(['*' => Process::result(errorOutput: 'ParseAddr("fde4:75c5:b9d4::1/64"): unexpected character, want colon (at "/64")', exitCode: 1)]);
$exception = captureDeploymentException(fn () => $job->execute_remote_command([
'command' => 'docker compose up -d',
]));
expect($exception->getMessage())
->toContain('ParseAddr("fde4:75c5:b9d4::1/64")')
->toContain('systemctl restart docker')
->toContain('28.0.1');
});
it('does not add the Docker network hint to other failures', function () {
[$job] = makeSensitiveCommandFailureJob();
Process::fake(['*' => Process::result(errorOutput: 'ParseAddr("fde4:75c5:b9d4::1 "): unexpected character', exitCode: 1)]);
$exception = captureDeploymentException(fn () => $job->execute_remote_command([
'command' => 'docker compose up -d',
]));
expect($exception->getMessage())->not->toContain('systemctl restart docker');
});
it('redacts locked values from the error output of failed commands', function () {
[$job] = makeSensitiveCommandFailureJob([
['key' => 'LOCKED_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_shown_once' => true],
]);
Process::fake(['*' => Process::result(errorOutput: 'failed with value '.SENSITIVE_FAILURE_SECRET, exitCode: 1)]);
$exception = captureDeploymentException(fn () => $job->execute_remote_command([
'command' => 'harmless-command --flag',
]));
expect($exception->getMessage())
->toContain('harmless-command --flag')
->toContain('failed with value '.REDACTED)
->not->toContain(SENSITIVE_FAILURE_SECRET);
});
it('logs only the variable names of the .env files in development mode', function () {
config()->set('app.env', 'local');
[$job, $reflection, $queue] = makeSensitiveCommandFailureJob([
['key' => 'APP_SECRET', 'value' => SENSITIVE_FAILURE_SECRET],
]);
$ranCommands = [];
Process::fake(function (PendingProcess $process) use (&$ranCommands) {
$ranCommands[] = remoteProcessScript($process);
return Process::result(output: str_contains(remoteProcessScript($process), 'cat ') ? 'APP_SECRET='.SENSITIVE_FAILURE_SECRET : '');
});
invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables');
invokeSensitiveFailureJobMethod($job, $reflection, 'save_buildtime_environment_variables');
$logEntries = collect(json_decode((string) $queue->fresh()->logs, true));
$keyListEntries = $logEntries->filter(fn (array $entry): bool => str_contains((string) $entry['output'], '[DEBUG] Variable names in'));
expect($keyListEntries)->toHaveCount(2)
->each(fn ($entry) => $entry->hidden->toBeTrue()->output->toContain('APP_SECRET'));
expect($logEntries->pluck('output')->merge($logEntries->pluck('command'))->filter()->implode("\n"))
->not->toContain('APP_SECRET='.SENSITIVE_FAILURE_SECRET)
->not->toContain('APP_SECRET="'.SENSITIVE_FAILURE_SECRET);
expect(collect($ranCommands)->filter(fn (string $command): bool => str_contains($command, 'cat /artifacts/sensitive-failure/.env') || str_contains($command, 'cat '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH)))
->toBeEmpty();
});
function setSensitiveFailureJobProperties(object $job, ReflectionClass $reflection, array $properties): void
{
foreach ($properties as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
}
it('hides the helper container command when build secrets are passed as environment flags', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([
['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false],
]);
$application->settings()->update(['use_build_secrets' => true]);
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
setSensitiveFailureJobProperties($job, $reflection, [
'application' => $application->fresh(),
'destination' => StandaloneDocker::forceCreate([
'name' => 'sensitive-failure-network',
'network' => 'sensitive-failure-network',
'server_id' => $server->id,
]),
]);
Process::fake(function (PendingProcess $process) {
if (str_contains(remoteProcessScript($process), 'docker run -d')) {
return Process::result(errorOutput: 'docker: Error response from daemon: network not found.', exitCode: 125);
}
return Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/root' : 'NOK');
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image'));
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->toContain('network not found')
->not->toContain(SENSITIVE_FAILURE_SECRET);
expect((string) $queue->fresh()->logs)->not->toContain(SENSITIVE_FAILURE_SECRET);
});
it('hides the Railpack prepare command that passes build-time values', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([
['key' => 'BUILD_SECRET', 'value' => SENSITIVE_FAILURE_SECRET, 'is_runtime' => false],
]);
$application->update(['build_pack' => 'railpack']);
setSensitiveFailureJobProperties($job, $reflection, [
'application' => $application->fresh(),
'build_pack' => 'railpack',
'dockerBuildxAvailable' => true,
]);
Process::fake(function (PendingProcess $process) {
if (str_contains(remoteProcessScript($process), 'railpack prepare')) {
return Process::result(errorOutput: 'railpack: failed to detect a provider', exitCode: 1);
}
return Process::result(output: str_contains(remoteProcessScript($process), 'buildx version') ? 'available' : 'missing');
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'build_railpack_image'));
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->toContain('failed to detect a provider')
->not->toContain(SENSITIVE_FAILURE_SECRET);
expect((string) $queue->fresh()->logs)->not->toContain(SENSITIVE_FAILURE_SECRET);
});
it('hides the Nixpacks plan write command that contains build-time values', function (bool $isStatic) {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
$application->update(['build_pack' => 'nixpacks']);
$application->settings()->update(['is_static' => $isStatic]);
setSensitiveFailureJobProperties($job, $reflection, [
'application' => $application->fresh(),
'build_pack' => 'nixpacks',
'build_args' => collect(),
'disableBuildCache' => false,
'force_rebuild' => false,
'nixpacks_plan' => json_encode(['variables' => ['BUILD_SECRET' => SENSITIVE_FAILURE_SECRET]]),
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
$commandPayloads = base64PayloadsInCommand(remoteProcessScript($process));
if ($commandPayloads !== []) {
$payloads = array_merge($payloads, $commandPayloads);
return Process::result(errorOutput: 'tee: /artifacts/thegameplan.json: No space left on device', exitCode: 1);
}
return Process::result();
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'build_image'));
expect($payloads)->not->toBeEmpty();
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->not->toContain(SENSITIVE_FAILURE_SECRET);
$storedLogs = (string) $queue->fresh()->logs;
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
expect($storedLogs)->not->toContain($payload);
}
})->with([
'static' => [true],
'not static' => [false],
]);
it('streams the compose file over stdin so its content stays out of commands, errors, and logs', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
setSensitiveFailureJobProperties($job, $reflection, [
'destination' => $server->standaloneDockers()->firstOrFail(),
'production_image_name' => 'example/app:latest',
]);
$composeWrite = null;
Process::fake(function (PendingProcess $process) use (&$composeWrite) {
if (str_contains((string) $process->input, 'services:')) {
$composeWrite = $process;
return Process::result(errorOutput: 'tee: docker-compose.yaml: No space left on device', exitCode: 1);
}
return Process::result();
});
invokeSensitiveFailureJobMethod($job, $reflection, 'generate_compose_file');
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'write_compose_file_to_workdir'));
$compose = (string) readSensitiveFailureJobProperty($job, $reflection, 'docker_compose');
expect($composeWrite)->not->toBeNull()
->and($composeWrite->input)->toBe($compose)
->and($composeWrite->command)->toEndWith(escapeshellarg('docker exec -i sensitive-failure-deployment tee /artifacts/sensitive-failure/docker-compose.yaml > /dev/null'))
->and($composeWrite->command)->not->toContain('services:')
->and($exception->getMessage())->toContain('No space left on device')
->and($exception->getMessage())->not->toContain('services:')
->and((string) $queue->fresh()->logs)->not->toContain('services:');
});
function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
{
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($job);
}
it('redacts shell-escaped remote secrets in command logs and later deployment log entries', function () {
[$job, $reflection, $queue] = makeSensitiveCommandFailureJob();
$remoteSecrets = $reflection->getProperty('remote_secrets_cache');
$remoteSecrets->setValue($job, ['DB_PASS' => "s3cr'et-value"]);
$escapedSecret = "'s3cr'\\''et-value'";
Process::fake(fn () => Process::result(output: "DB_PASS={$escapedSecret}"));
invokeSensitiveFailureJobMethod($job, $reflection, 'execute_remote_command', ["echo DB_PASS={$escapedSecret}"]);
$queue->addLogEntry("Build argument DB_PASS={$escapedSecret}");
$storedLogs = collect(json_decode((string) $queue->fresh()->logs, true));
expect($storedLogs)->toHaveCount(2)
->and($storedLogs->toJson())->not->toContain('s3cr')
->and($storedLogs->pluck('output')->all())->each->toContain('DB_PASS='.REDACTED);
});
function linkSensitiveFailureSecretManager(Application $application): void
{
$token = IntegrationToken::query()->create([
'team_id' => $application->team()->id,
'provider' => 'doppler',
'name' => 'Doppler',
'token' => 'the-secret-token',
'capabilities' => ['secrets'],
]);
$application->secretManagerLink()->create([
'integration_token_id' => $token->id,
'settings' => ['project' => 'app', 'config' => 'prd'],
]);
}
it('redacts fetched remote secrets in log entries the deployment adds before its next remote command', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
linkSensitiveFailureSecretManager($application);
Http::fake(['https://api.doppler.com/*' => Http::response(['DB_PASS' => 'fetched-remote-secret-5521'])]);
invokeSensitiveFailureJobMethod($job, $reflection, 'remote_secrets');
$queue->addLogEntry('Resolved DB_PASS=fetched-remote-secret-5521');
$storedLogs = (string) $queue->fresh()->logs;
expect($storedLogs)->not->toContain('fetched-remote-secret-5521')
->toContain('DB_PASS='.REDACTED);
});
it('keeps a literal vault reference as text when the application has no secret manager source', function () {
[$job, $reflection, $queue] = makeSensitiveCommandFailureJob([
['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true],
]);
Http::fake();
$runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables');
$buildtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
invokeSensitiveFailureJobMethod($job, $reflection, 'generate_env_variables');
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('{{vault.FOO}}'))
->and($buildtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'")
->and(readSensitiveFailureJobProperty($job, $reflection, 'env_args')->get('LITERAL_REF'))->toBe("'{{vault.FOO}}'")
->and(invokeSensitiveFailureJobMethod($job, $reflection, 'has_remote_buildtime_secret_references'))->toBeFalse();
Http::assertNothingSent();
});
it('still fails a deployment when a non-literal vault reference has no secret manager source', function () {
[$job, $reflection] = makeSensitiveCommandFailureJob([
['key' => 'REMOTE_REF', 'value' => '{{vault.FOO}}', 'is_literal' => false],
]);
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables'));
expect($exception->getMessage())->toContain('no secret manager source is configured');
});
it('resolves a literal vault reference when the application has a secret manager source', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob([
['key' => 'LITERAL_REF', 'value' => '{{vault.FOO}}', 'is_literal' => true],
]);
linkSensitiveFailureSecretManager($application);
Http::fake(['https://api.doppler.com/*' => Http::response(['FOO' => 'resolved-remote-value'])]);
$runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('resolved-remote-value'));
});
it('tells how to log in when the build server has no Docker config for the SSH user', function () {
[$job, $reflection] = makeSensitiveCommandFailureJob();
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
$server->forceFill(['user' => 'coolify', 'name' => 'build-1'])->save();
setSensitiveFailureJobProperties($job, $reflection, ['server' => $server->fresh(), 'use_build_server' => true]);
Process::fake(fn (PendingProcess $process) => Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/home/coolify' : 'NOK'));
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image'));
expect($exception->getMessage())
->toContain('of the SSH user (coolify) not found on the build server (build-1)')
->toContain('Server → Docker Registries')
->toContain('"sudo docker login" is saved for root');
Process::assertNotRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker run -d'));
});
it('mounts the seccomp profile from custom docker options into the helper container', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
$application->update(['custom_docker_run_options' => '--security-opt seccomp=/etc/docker/seccomp-profile.json']);
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
setSensitiveFailureJobProperties($job, $reflection, [
'application' => $application->fresh(),
'destination' => StandaloneDocker::forceCreate([
'name' => 'seccomp-network',
'network' => 'seccomp-network',
'server_id' => $server->id,
]),
]);
$helperCommands = [];
Process::fake(function (PendingProcess $process) use (&$helperCommands) {
if (str_contains(remoteProcessScript($process), 'docker run -d')) {
$helperCommands[] = remoteProcessScript($process);
return Process::result(errorOutput: 'docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /etc/docker/seccomp-profile.json', exitCode: 125);
}
return Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/root' : 'NOK');
});
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image'));
expect($helperCommands)->toHaveCount(1)
->and($helperCommands[0])->toContain('type=bind,source=/etc/docker/seccomp-profile.json,target=/etc/docker/seccomp-profile.json,readonly')
->and($exception->getMessage())->toContain('bind source path does not exist: /etc/docker/seccomp-profile.json');
});