From bf957e425fe9bbafb5675e4197ecc192d41b7d61 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:12:16 +0200 Subject: [PATCH] fix(docker): parse quoted and hyphenated custom docker option values (#12208) --- app/Jobs/ApplicationDeploymentJob.php | 10 +- bootstrap/helpers/constants.php | 23 ++ bootstrap/helpers/docker.php | 154 +++++++----- .../DeploymentSensitiveCommandFailureTest.php | 31 +++ .../Helpers/DockerCustomCommandsTest.php | 235 ++++++++++++++++++ ...ationDeploymentCustomDockerOptionsTest.php | 21 +- 6 files changed, 411 insertions(+), 63 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f22447d135..80b92f7723 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2905,12 +2905,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { + // docker compose runs in this helper and reads seccomp profile files from the custom Docker options + $seccompMounts = $this->pull_request_id === 0 ? seccompProfileMountFlags($this->application->custom_docker_run_options) : ''; if ($this->dockerConfigFileExists === 'OK') { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } else { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } } if ($firstTry) { @@ -4291,6 +4293,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); + $unsupported_options = unsupportedDockerRunOptions($this->application->custom_docker_run_options); + if (count($unsupported_options) > 0) { + $this->application_deployment_queue->addLogEntry('Warning: These custom Docker options are not supported and were not applied: '.implode(', ', $unsupported_options).'. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr'); + } if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; if ($this->container_name !== $this->application->uuid) { diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 543901932f..6dd1155a20 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -23,6 +23,29 @@ const STANDALONE_DATABASE_MODELS = [ 'clickhouse' => StandaloneClickhouse::class, 'sqlite' => StandaloneSqlite::class, ]; +// Custom Docker options that Coolify converts to Compose; other options conflict with settings Coolify manages +const DOCKER_RUN_OPTION_MAPPING = [ + '--add-host' => 'extra_hosts', + '--cap-add' => 'cap_add', + '--cap-drop' => 'cap_drop', + '--security-opt' => 'security_opt', + '--sysctl' => 'sysctls', + '--device' => 'devices', + '--init' => 'init', + '--ulimit' => 'ulimits', + '--privileged' => 'privileged', + '--ip' => 'ip', + '--ip6' => 'ip6', + '--shm-size' => 'shm_size', + '--dns' => 'dns', + '--gpus' => 'gpus', + '--hostname' => 'hostname', + '--entrypoint' => 'entrypoint', + '--pids-limit' => 'pids_limit', + '--read-only' => 'read_only', + '--runtime' => 'runtime', + '--tmpfs' => 'tmpfs', +]; const VALID_CRON_STRINGS = [ 'every_minute' => '* * * * *', 'hourly' => '0 * * * *', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index b2fed22add..4517fb11f2 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1598,8 +1598,11 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) { $options = []; $compose_options = collect([]); - preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?([^\s-]+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + // A value is one shell word: double-quoted (with escapes), single-quoted, and unquoted parts, as in key="a b". + // It cannot start with "-" (except a negative number, as in --pids-limit -1), so the next flag is not taken as a value. + preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?((?!-(?!\d))(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ + '--add-host', '--cap-add', '--cap-drop', '--security-opt', @@ -1608,42 +1611,32 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--device', '--shm-size', '--dns', + '--tmpfs', ]); - $mapping = collect([ - '--cap-add' => 'cap_add', - '--cap-drop' => 'cap_drop', - '--security-opt' => 'security_opt', - '--sysctl' => 'sysctls', - '--device' => 'devices', - '--init' => 'init', - '--ulimit' => 'ulimits', - '--privileged' => 'privileged', - '--ip' => 'ip', - '--ip6' => 'ip6', - '--shm-size' => 'shm_size', - '--dns' => 'dns', - '--gpus' => 'gpus', - '--hostname' => 'hostname', - '--entrypoint' => 'entrypoint', - ]); + $mapping = collect(DOCKER_RUN_OPTION_MAPPING); foreach ($matches as $match) { $option = $match[1]; + // Remove the quotes like a shell: unescape \" \$ \` \\ in double quotes, keep single-quoted text as is + $value = preg_replace_callback( + '/"((?:\\\\.|[^"])*)"|\'([^\']*)\'/', + fn ($quoted) => isset($quoted[2]) ? $quoted[2] : preg_replace('/\\\\(["$`\\\\])/', '$1', $quoted[1]), + $match[2] ?? '' + ); if ($option === '--gpus') { - $regexForParsingDeviceIds = '/device=([0-9A-Za-z-,]+)/'; - preg_match($regexForParsingDeviceIds, $custom_docker_run_options, $device_matches); - $value = $device_matches[1] ?? 'all'; - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); - } - if ($option === '--hostname') { - // Match --hostname=value or --hostname value - $regexForParsingHostname = '/--hostname(?:=|\s+)([^\s]+)/'; - preg_match($regexForParsingHostname, $custom_docker_run_options, $hostname_matches); - $value = $hostname_matches[1] ?? null; - if ($value && ! empty(trim($value))) { - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); + // all, a count (2), or device IDs ("device=0,1"; the inner quotes are part of the Docker syntax) + $gpus = trim($value, '"'); + $payload = [ + 'driver' => 'nvidia', + 'capabilities' => ['gpu'], + ]; + if (preg_match('/^device=([0-9A-Za-z-,]+)$/', $gpus, $device_matches)) { + $payload['device_ids'] = explode(',', $device_matches[1]); + } elseif (ctype_digit($gpus)) { + $payload['count'] = (int) $gpus; } + $options[$option] = $payload; + + continue; } if ($option === '--entrypoint') { $value = null; @@ -1677,14 +1670,18 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) continue; } - if (isset($match[2]) && $match[2] !== '') { - $value = $match[2]; - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); - } else { - $value = true; - $options[$option] = $value; + if (in_array($option, ['--init', '--privileged', '--read-only'], true)) { + // Boolean flags only take a value with "=" (--privileged=false); a space-separated word is not their value + $hasInlineValue = str_starts_with(substr($match[0], strlen($option)), '='); + $options[$option] = ! ($hasInlineValue && strtolower($value) === 'false'); + + continue; } + if (trim($value) === '') { + continue; + } + $options[$option][] = $value; + $options[$option] = array_unique($options[$option]); } $options = collect($options); // Easily get mappings from https://github.com/composerize/composerize/blob/master/packages/composerize/src/mappings.js @@ -1695,7 +1692,10 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) if ($option === '--ulimit') { $ulimits = collect([]); collect($value)->map(function ($ulimit) use ($ulimits) { - $ulimit = explode('=', $ulimit); + if (! str_contains($ulimit, '=')) { + return; + } + $ulimit = explode('=', $ulimit, 2); $type = $ulimit[0]; $limits = explode(':', $ulimit[1]); if (count($limits) == 2) { @@ -1706,17 +1706,21 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) 'hard' => $hard_limit, ]); } else { - $soft_limit = $ulimit[1]; - $ulimits->put($type, [ - 'soft' => $soft_limit, - ]); + // Compose needs both soft and hard in the mapping form, so a single limit is set directly + $ulimits->put($type, $ulimit[1]); } }); - $compose_options->put($mapping[$option], $ulimits); - } elseif ($option === '--shm-size' || $option === '--hostname') { + if ($ulimits->isNotEmpty()) { + $compose_options->put($mapping[$option], $ulimits); + } + } elseif (in_array($option, ['--shm-size', '--hostname', '--runtime'], true)) { if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { $compose_options->put($mapping[$option], $value[0]); } + } elseif ($option === '--pids-limit') { + if (is_numeric($value[0])) { + $compose_options->put($mapping[$option], (int) $value[0]); + } } elseif ($option === '--entrypoint') { if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { // Docker compose accepts entrypoint as either a string or an array @@ -1724,23 +1728,10 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $compose_options->put($mapping[$option], $value[0]); } } elseif ($option === '--gpus') { - $payload = [ - 'driver' => 'nvidia', - 'capabilities' => ['gpu'], - ]; - if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { - if (str($value[0]) != 'all') { - if (str($value[0])->contains(',')) { - $payload['device_ids'] = str($value[0])->explode(',')->toArray(); - } else { - $payload['device_ids'] = [$value[0]]; - } - } - } $compose_options->put('deploy', [ 'resources' => [ 'reservations' => [ - 'devices' => [$payload], + 'devices' => [$value], ], ], ]); @@ -1764,6 +1755,45 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) return $compose_options->toArray(); } +/** + * Returns the options (--network, -e, ...) that convertDockerRunToCompose() does not apply. + * + * @return array + */ +function unsupportedDockerRunOptions(?string $custom_docker_run_options): array +{ + // Split into shell words, so text inside a quoted value is not taken as an option + preg_match_all('/(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+/', $custom_docker_run_options ?? '', $words); + $unsupported = []; + foreach ($words[0] as $word) { + if (! str_starts_with($word, '-') || preg_match('/^-\d/', $word)) { + continue; + } + $option = str_starts_with($word, '--') ? '--'.ltrim(strtok($word, '='), '-') : strtok($word, '='); + if (! array_key_exists($option, DOCKER_RUN_OPTION_MAPPING)) { + $unsupported[] = $option; + } + } + + return array_values(array_unique($unsupported)); +} + +/** + * Returns --mount flags that make seccomp profile files from the custom Docker options readable in the deployment + * helper container, where docker compose reads them. The path stays the same, so security_opt works unchanged. + */ +function seccompProfileMountFlags(?string $custom_docker_run_options): string +{ + $flags = []; + foreach (data_get(convertDockerRunToCompose($custom_docker_run_options), 'security_opt', []) as $security_opt) { + if (preg_match('#^seccomp[=:](/[A-Za-z0-9._/-]+)$#', $security_opt, $profile) && ! str_contains($profile[1], '..')) { + $flags[] = '--mount '.escapeshellarg("type=bind,source={$profile[1]},target={$profile[1]},readonly"); + } + } + + return implode(' ', array_unique($flags)); +} + function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $network) { $ipv4 = data_get($docker_run_options, 'ip.0'); @@ -1779,6 +1809,10 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker if ($ipv6) { $docker_compose['services'][$container_name]['networks'][$network]['ipv6_address'] = $ipv6; } + // A custom ulimit replaces the default of the same type; a recursive merge would combine both into an invalid value + foreach (array_keys(data_get($docker_run_options, 'ulimits', [])) as $type) { + unset($docker_compose['services'][$container_name]['ulimits'][$type]); + } $docker_compose['services'][$container_name] = array_merge_recursive($docker_compose['services'][$container_name], $docker_run_options); return $docker_compose; diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index a879ee286e..c0c4882168 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -560,3 +560,34 @@ it('resolves a literal vault reference when the application has a secret manager expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('resolved-remote-value')); }); + +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($process->command, 'docker run -d')) { + $helperCommands[] = $process->command; + + 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($process->command, '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'); +}); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 299709ffd9..7a16d15446 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -233,3 +233,238 @@ test('ConvertIpAndIp6Together', function () { 'ip6' => ['2001:db8::1'], ]); }); + +test('ConvertSecurityOptWithHyphenatedValue', function () { + $input = '--cap-drop ALL --security-opt no-new-privileges:true --security-opt=apparmor:unconfined'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_drop' => ['ALL'], + 'security_opt' => ['no-new-privileges:true', 'apparmor:unconfined'], + ]); +}); + +test('ConvertQuotedSecurityOpt', function (string $input) { + expect(convertDockerRunToCompose($input))->toBe([ + 'security_opt' => ['no-new-privileges:true'], + ]); +})->with([ + '--security-opt="no-new-privileges:true"', + "--security-opt='no-new-privileges:true'", + '--security-opt "no-new-privileges:true"', + "--security-opt 'no-new-privileges:true'", +]); + +test('ConvertQuotedValuesForListAndScalarOptions', function () { + $input = '--cap-add "NET_ADMIN" --cap-drop \'ALL\' --sysctl "net.core.somaxconn=1024" --device "/dev/snd" --dns "8.8.8.8" --ip="10.0.0.5" --shm-size "2g" --hostname \'my-host\' --security-opt "label=type:my type"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + 'cap_drop' => ['ALL'], + 'sysctls' => ['net.core.somaxconn=1024'], + 'devices' => ['/dev/snd'], + 'dns' => ['8.8.8.8'], + 'ip' => ['10.0.0.5'], + 'shm_size' => '2g', + 'hostname' => 'my-host', + 'security_opt' => ['label=type:my type'], + ]); +}); + +test('ConvertUnescapesDoubleQuotedValue', function () { + $input = '--security-opt "label=type:\"my\" type"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'security_opt' => ['label=type:"my" type'], + ]); +}); + +test('ConvertBooleanFlagFollowedByShortFlag', function () { + $input = '--init -e FOO=bar --privileged -v /a:/b'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + 'privileged' => true, + ]); +}); + +test('ConvertBooleanFlagWithInlineValue', function () { + $input = '--privileged=false --init=true'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'privileged' => false, + 'init' => true, + ]); +}); + +test('ConvertBooleanFlagIgnoresSpaceSeparatedWord', function () { + $input = '--privileged false --init "x"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'privileged' => true, + 'init' => true, + ]); +}); + +test('ConvertUlimitWithSingleValue', function () { + $input = '--ulimit nofile=1024 --ulimit nproc=512:1024'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ulimits' => [ + 'nofile' => '1024', + 'nproc' => [ + 'soft' => '512', + 'hard' => '1024', + ], + ], + ]); +}); + +test('ConvertIgnoresUlimitWithoutType', function () { + $input = '--ulimit 1024 --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + ]); +}); + +test('ConvertIgnoresOptionWithoutValue', function () { + $input = '--cap-add -e FOO=bar --cap-add NET_ADMIN --ulimit --dns'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + ]); +}); + +test('ConvertIgnoresEmptyQuotedValue', function () { + $input = '--security-opt="" --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + ]); +}); + +test('ConvertDoesNotParseOptionsInsideQuotedEntrypoint', function () { + $input = '--entrypoint "sh -c \'exec app --privileged --cap-add=SYS_ADMIN\'" --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'entrypoint' => "sh -c 'exec app --privileged --cap-add=SYS_ADMIN'", + 'init' => true, + ]); +}); + +test('ConvertValueAfterMultipleWhitespace', function () { + $input = "--cap-add NET_ADMIN --security-opt \t no-new-privileges:true --dns\t\t1.1.1.1"; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + 'security_opt' => ['no-new-privileges:true'], + 'dns' => ['1.1.1.1'], + ]); +}); + +test('DatabaseCustomUlimitReplacesDefaultOfSameType', function () { + $dockerCompose = [ + 'services' => [ + 'db' => [ + 'image' => 'clickhouse/clickhouse-server', + 'ulimits' => ['nofile' => ['soft' => 262144, 'hard' => 262144]], + ], + ], + ]; + $options = convertDockerRunToCompose('--ulimit nofile=1024:2048 --ulimit nproc=512'); + $output = generateCustomDockerRunOptionsForDatabases($options, $dockerCompose, 'db', 'coolify'); + expect($output['services']['db']['ulimits'])->toBe([ + 'nofile' => ['soft' => '1024', 'hard' => '2048'], + 'nproc' => '512', + ]); +}); + +test('DatabaseDefaultUlimitIsKeptWhenNotOverridden', function () { + $dockerCompose = [ + 'services' => [ + 'db' => [ + 'image' => 'clickhouse/clickhouse-server', + 'ulimits' => ['nofile' => ['soft' => 262144, 'hard' => 262144]], + ], + ], + ]; + $options = convertDockerRunToCompose('--ulimit nproc=512'); + $output = generateCustomDockerRunOptionsForDatabases($options, $dockerCompose, 'db', 'coolify'); + expect($output['services']['db']['ulimits'])->toBe([ + 'nofile' => ['soft' => 262144, 'hard' => 262144], + 'nproc' => '512', + ]); +}); + +test('ConvertValueWithQuotedPart', function () { + $input = '--sysctl net.ipv4.ping_group_range="0 2147483647" --ulimit nofile="1024:2048" --security-opt label="type:svirt_apache_t"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'sysctls' => ['net.ipv4.ping_group_range=0 2147483647'], + 'ulimits' => [ + 'nofile' => [ + 'soft' => '1024', + 'hard' => '2048', + ], + ], + 'security_opt' => ['label=type:svirt_apache_t'], + ]); +}); + +test('ConvertGpusCount', function () { + $input = '--gpus 2'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'deploy' => [ + 'resources' => [ + 'reservations' => [ + 'devices' => [ + [ + 'driver' => 'nvidia', + 'capabilities' => ['gpu'], + 'count' => 2, + ], + ], + ], + ], + ], + ]); +}); + +test('ConvertRuntimeHardeningOptions', function () { + $input = '--add-host host.docker.internal:host-gateway --add-host=db:10.0.0.5 --pids-limit 100 --runtime runc --read-only --tmpfs /run:rw,size=64m --tmpfs /tmp'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'extra_hosts' => ['host.docker.internal:host-gateway', 'db:10.0.0.5'], + 'pids_limit' => 100, + 'runtime' => 'runc', + 'read_only' => true, + 'tmpfs' => ['/run:rw,size=64m', '/tmp'], + ]); +}); + +test('ConvertNegativePidsLimitAndDisabledReadOnly', function () { + $input = '--pids-limit -1 --read-only=false'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'pids_limit' => -1, + 'read_only' => false, + ]); +}); + +test('UnsupportedDockerRunOptions', function () { + $input = '--network=host --name app -e FOO=bar -v /a:/b --entrypoint "sh -c \'run --verbose\'" --cap-add NET_ADMIN --pids-limit -1 ---privileged --network bridge'; + expect(unsupportedDockerRunOptions($input))->toBe(['--network', '--name', '-e', '-v']); +}); + +test('UnsupportedDockerRunOptionsIsEmptyForSupportedOptions', function () { + expect(unsupportedDockerRunOptions('--cap-drop ALL --security-opt no-new-privileges:true --tmpfs /tmp'))->toBe([]) + ->and(unsupportedDockerRunOptions(null))->toBe([]); +}); + +test('SeccompProfileMountFlags', function () { + $input = '--security-opt seccomp=/etc/docker/seccomp.json --security-opt "seccomp:/srv/app profile.json" --security-opt seccomp=/etc/../root/x.json --security-opt seccomp=unconfined --security-opt apparmor=unconfined'; + expect(seccompProfileMountFlags($input))->toBe("--mount 'type=bind,source=/etc/docker/seccomp.json,target=/etc/docker/seccomp.json,readonly'") + ->and(seccompProfileMountFlags('--cap-drop ALL'))->toBe('') + ->and(seccompProfileMountFlags(null))->toBe(''); +}); diff --git a/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php index 234f216bd2..86a694f49b 100644 --- a/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php +++ b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php @@ -22,7 +22,7 @@ class TestableCustomDockerOptionsDeploymentJob extends ApplicationDeploymentJob public function execute_remote_command(...$commands): void {} } -function generateComposeServiceWithCustomDockerOptions(string $customDockerOptions): array +function generateComposeServiceWithCustomDockerOptions(string $customDockerOptions, ?Closure $expectOnQueue = null): array { $team = Team::create([ 'name' => 'Custom Docker Options Team', @@ -52,6 +52,9 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); $queue->status = 'queued'; $queue->shouldReceive('refresh')->once()->andReturnSelf(); + if ($expectOnQueue) { + $expectOnQueue($queue); + } $application = $application->fresh(); $job = new TestableCustomDockerOptionsDeploymentJob; @@ -96,3 +99,19 @@ it('preserves custom network aliases when a static IP is configured', function ( ->toHaveKey('aliases') ->and($network['aliases'])->toContain('custom-alias'); }); + +it('warns in the deployment log about unsupported custom docker options', function () { + $service = generateComposeServiceWithCustomDockerOptions('--network=host -e FOO=bar --init', function ($queue) { + $queue->shouldReceive('addLogEntry') + ->once() + ->with('Warning: These custom Docker options are not supported and were not applied: --network, -e. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr'); + }); + + expect($service)->toHaveKey('init', true); +}); + +it('does not warn when all custom docker options are supported', function () { + generateComposeServiceWithCustomDockerOptions('--init --read-only --tmpfs /tmp', function ($queue) { + $queue->shouldNotReceive('addLogEntry'); + }); +});