mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
fix(docker): parse quoted and hyphenated custom docker option values (#12208)
This commit is contained in:
1 parent
71131804f6
commit
bf957e425f
6 files changed
+411
-63
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 * * * *',
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user