From 31c3fcf50d7abca3d266b17e2dfc277936f42990 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:19:37 +0100 Subject: [PATCH] feat(scheduled-tasks): add an option to run a task without a shell Containers without sh (distroless or scratch images) could not run scheduled tasks, because Coolify always ran the command with sh -c. Turn off "Run in a shell" to split the command into arguments and run it directly. The option is also in the API, the MCP tools, and the server transfer. Closes #8244 Co-Authored-By: Claude Opus 5.5 --- .../Api/ScheduledTasksController.php | 11 ++- app/Jobs/ScheduledTaskJob.php | 3 +- .../Project/Shared/ScheduledTask/Add.php | 6 ++ .../Project/Shared/ScheduledTask/Show.php | 5 + app/Mcp/Tools/ListScheduledTasks.php | 1 + app/Mcp/Tools/ManageScheduledTask.php | 6 +- app/Models/ScheduledTask.php | 95 +++++++++++++++++++ .../ServerTransfer/ServerTransferExporter.php | 1 + .../ServerTransfer/ServerTransferImporter.php | 1 + ...add_use_shell_to_scheduled_tasks_table.php | 22 +++++ docs/server-transfer-full-coverage.md | 2 +- openapi.json | 24 +++++ openapi.yaml | 19 ++++ .../shared/scheduled-task/add.blade.php | 2 + .../shared/scheduled-task/show.blade.php | 5 + .../Api/ScheduledTaskDeployAbilityTest.php | 30 ++++++ .../Feature/ContainerNameShellQuotingTest.php | 19 ++++ .../ScheduledTaskContainerCommandTest.php | 32 +++++++ tests/Feature/ScheduledTaskModalTest.php | 20 ++++ .../ServerTransferExporterImporterTest.php | 7 +- 20 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 database/migrations/2026_11_03_202635_add_use_shell_to_scheduled_tasks_table.php create mode 100644 tests/Feature/ScheduledTaskContainerCommandTest.php diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index 2f120eccbb..db982fe3a1 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -78,7 +78,7 @@ class ScheduledTasksController extends Controller return $return; } - $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'use_shell', 'enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', @@ -86,6 +86,7 @@ class ScheduledTasksController extends Controller 'frequency' => 'required|string', 'container' => 'string|nullable', 'timeout' => 'integer|min:1|max:36000', + 'use_shell' => 'boolean', 'enabled' => 'boolean', ]); @@ -124,6 +125,7 @@ class ScheduledTasksController extends Controller $task->frequency = $request->frequency; $task->container = $request->container; $task->timeout = $request->has('timeout') ? $request->timeout : 300; + $task->use_shell = $request->has('use_shell') ? $request->boolean('use_shell') : true; $task->enabled = $enabled; $task->team_id = $teamId; @@ -159,7 +161,7 @@ class ScheduledTasksController extends Controller return response()->json(['message' => 'At least one field must be provided.'], 422); } - $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled']; + $allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'use_shell', 'enabled']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -167,6 +169,7 @@ class ScheduledTasksController extends Controller 'frequency' => 'string', 'container' => 'string|nullable', 'timeout' => 'integer|min:1|max:36000', + 'use_shell' => 'boolean', 'enabled' => 'boolean', ]); @@ -376,6 +379,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], + 'use_shell' => ['type' => 'boolean', 'description' => 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.', 'default' => true], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), @@ -467,6 +471,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], + 'use_shell' => ['type' => 'boolean', 'description' => 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.', 'default' => true], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), @@ -747,6 +752,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], + 'use_shell' => ['type' => 'boolean', 'description' => 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.', 'default' => true], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), @@ -838,6 +844,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], + 'use_shell' => ['type' => 'boolean', 'description' => 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.', 'default' => true], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 28c54a3457..2a12135080 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -195,9 +195,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || $containerName === $this->task->container || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { - $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; - $execCommand = "{$dockerCommand} exec ".escapeshellarg($containerName)." {$cmd}"; + $execCommand = "{$dockerCommand} exec ".escapeshellarg($containerName).' '.$this->task->containerCommand(); $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index a7b14e04c5..8d7ff2ce21 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -39,6 +39,8 @@ class Add extends Component public int $timeout = 300; + public bool $useShell = true; + #[Locked] public string $serverTimezone = 'UTC'; @@ -48,6 +50,7 @@ class Add extends Component 'frequency' => 'required|string', 'container' => 'nullable|string', 'timeout' => 'required|integer|min:60|max:36000', + 'useShell' => 'boolean', ]; protected $validationAttributes = [ @@ -56,6 +59,7 @@ class Add extends Component 'frequency' => 'frequency', 'container' => 'container', 'timeout' => 'timeout', + 'useShell' => 'run in a shell', ]; public function mount() @@ -116,6 +120,7 @@ class Add extends Component $task->frequency = $this->frequency; $task->container = $this->container; $task->timeout = $this->timeout; + $task->use_shell = $this->useShell; $task->team_id = $this->resource->team()->id; switch ($this->type) { @@ -152,5 +157,6 @@ class Add extends Component $this->frequency = ''; $this->container = ''; $this->timeout = 300; + $this->useShell = true; } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 7af32d8381..6decb408da 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -45,6 +45,9 @@ class Show extends Component #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; + #[Validate(['boolean'])] + public bool $useShell = true; + #[Locked] public string $serverTimezone = 'UTC'; @@ -76,6 +79,7 @@ class Show extends Component $this->task->frequency = str($this->frequency)->trim()->value(); $this->task->container = str($this->container)->trim()->value(); $this->task->timeout = (int) $this->timeout; + $this->task->use_shell = $this->useShell; $changedFields = auditChangedFields($this->task); $this->task->save(); @@ -87,6 +91,7 @@ class Show extends Component $this->frequency = $this->task->frequency; $this->container = $this->task->container; $this->timeout = $this->task->timeout ?? 300; + $this->useShell = $this->task->use_shell ?? true; } return []; diff --git a/app/Mcp/Tools/ListScheduledTasks.php b/app/Mcp/Tools/ListScheduledTasks.php index 4062d47d21..20341f2fbb 100644 --- a/app/Mcp/Tools/ListScheduledTasks.php +++ b/app/Mcp/Tools/ListScheduledTasks.php @@ -69,6 +69,7 @@ class ListScheduledTasks extends Tool 'frequency' => $task->frequency, 'container' => $task->container, 'timeout' => $task->timeout, + 'use_shell' => $task->use_shell, 'command_included' => $includeCommand, ]; if ($includeCommand) { diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php index 0f4da5f91d..eaeddfbe90 100644 --- a/app/Mcp/Tools/ManageScheduledTask.php +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -19,11 +19,11 @@ class ManageScheduledTask extends RestActionTool 'create' => ['method' => 'POST', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks', 'service' => 'services/{uuid}/scheduled-tasks', - ], 'ability' => 'write', 'description' => 'create a scheduled task (data: name, command, frequency, container, timeout, enabled; an enabled task needs deploy ability)'], + ], 'ability' => 'write', 'description' => 'create a scheduled task (data: name, command, frequency, container, timeout, use_shell, enabled; an enabled task needs deploy ability)'], 'update' => ['method' => 'PATCH', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', - ], 'ability' => 'write', 'description' => 'update a scheduled task (data: any of name, command, frequency, container, timeout, enabled; an enabled task needs deploy ability)'], + ], 'ability' => 'write', 'description' => 'update a scheduled task (data: any of name, command, frequency, container, timeout, use_shell, enabled; an enabled task needs deploy ability)'], 'delete' => ['method' => 'DELETE', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', @@ -45,6 +45,6 @@ class ManageScheduledTask extends RestActionTool protected function dataDescription(): string { - return 'Request body, same fields as the REST API: name, command, frequency (cron or every_minute, hourly, daily, weekly, monthly, yearly), container, timeout (seconds), enabled.'; + return 'Request body, same fields as the REST API: name, command, frequency (cron or every_minute, hourly, daily, weekly, monthly, yearly), container, timeout (seconds), use_shell (false runs the command without sh -c, for containers without a shell), enabled.'; } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index c6224f2eda..fc8f027267 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -21,6 +21,7 @@ use OpenApi\Attributes as OA; 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'], + 'use_shell' => ['type' => 'boolean', 'description' => 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.'], 'consecutive_failures' => ['type' => 'integer', 'description' => 'The number of failed runs since the last successful run.'], 'failing_since' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'The date and time of the first failed run since the last successful run.'], 'disabled_reason' => ['type' => 'string', 'nullable' => true, 'description' => 'Why Coolify disabled the task automatically. It is cleared when the task is enabled again.'], @@ -41,6 +42,7 @@ class ScheduledTask extends BaseModel 'frequency', 'container', 'timeout', + 'use_shell', 'team_id', 'application_id', 'service_id', @@ -83,6 +85,7 @@ class ScheduledTask extends BaseModel return [ 'enabled' => 'boolean', 'timeout' => 'integer', + 'use_shell' => 'boolean', 'consecutive_failures' => 'integer', 'failing_since' => 'datetime', ]; @@ -125,6 +128,98 @@ class ScheduledTask extends BaseModel return NotificationThrottle::release($this, TaskFailed::class); } + /** + * The part of `docker exec ...` that runs the command, quoted for the host shell. + * Without a shell, the command is split into words like a POSIX shell does, but nothing is expanded, + * so it also runs in containers that have no `sh` (distroless or scratch images). + */ + public function containerCommand(): string + { + if ($this->use_shell ?? true) { + return 'sh -c '.escapeshellarg($this->command); + } + + $arguments = self::splitCommandArguments($this->command); + if ($arguments === []) { + throw new \InvalidArgumentException('The command is empty.'); + } + + return implode(' ', array_map(escapeshellarg(...), $arguments)); + } + + /** + * @return list + */ + private static function splitCommandArguments(string $command): array + { + $arguments = []; + $word = null; + $quote = null; + $length = strlen($command); + + for ($i = 0; $i < $length; $i++) { + $char = $command[$i]; + $next = $command[$i + 1] ?? null; + + if ($quote === "'") { + if ($char === "'") { + $quote = null; + } else { + $word .= $char; + } + + continue; + } + + if ($quote === '"') { + if ($char === '"') { + $quote = null; + } elseif ($char === '\\' && in_array($next, ['"', '\\', '$', '`'], true)) { + $word .= $next; + $i++; + } else { + $word .= $char; + } + + continue; + } + + if ($char === '\\' && $next === "\n") { + $i++; + + continue; + } + + if (ctype_space($char)) { + if ($word !== null) { + $arguments[] = $word; + $word = null; + } + + continue; + } + + $word ??= ''; + if ($char === "'" || $char === '"') { + $quote = $char; + } elseif ($char === '\\' && $next !== null) { + $word .= $next; + $i++; + } else { + $word .= $char; + } + } + + if ($quote !== null) { + throw new \InvalidArgumentException('The command has a quote that is not closed.'); + } + if ($word !== null) { + $arguments[] = $word; + } + + return $arguments; + } + public function url(): ?string { $resource = $this->application ?? $this->service; diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 64040ed02f..3ff63dd156 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -1324,6 +1324,7 @@ class ServerTransferExporter 'frequency' => $task->frequency, 'container' => $task->container, 'timeout' => $task->timeout, + 'use_shell' => (bool) ($task->use_shell ?? true), 'enabled' => (bool) ($task->enabled ?? true), 'disabled_reason' => $task->disabled_reason, ]; diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 7a9816bce7..a7ae8af1f1 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -1688,6 +1688,7 @@ class ServerTransferImporter 'frequency' => data_get($task, 'frequency'), 'container' => data_get($task, 'container'), 'timeout' => data_get($task, 'timeout'), + 'use_shell' => (bool) data_get($task, 'use_shell', true), 'enabled' => (bool) data_get($task, 'enabled', true), 'disabled_reason' => data_get($task, 'disabled_reason'), 'team_id' => $teamId, diff --git a/database/migrations/2026_11_03_202635_add_use_shell_to_scheduled_tasks_table.php b/database/migrations/2026_11_03_202635_add_use_shell_to_scheduled_tasks_table.php new file mode 100644 index 0000000000..1471f213db --- /dev/null +++ b/database/migrations/2026_11_03_202635_add_use_shell_to_scheduled_tasks_table.php @@ -0,0 +1,22 @@ +boolean('use_shell')->default(true); + }); + } + + public function down(): void + { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->dropColumn('use_shell'); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index acc7c4f8ed..b7c2509276 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -101,7 +101,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ✅ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on | | ✅ | File mount + directory mount | content, chown, chmod | | ✅ | Volume backup | on the persistent volume: S3 on, stop during backup, all retention values, timeout, missing-backup days | -| ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout; an auto-disabled task keeps its disabled reason | +| ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout + "Run in a shell" off; an auto-disabled task keeps its disabled reason | | ✅ | Tag | | | ✅ | Preview deployment | PR preview with domain port overrides + max restart count + keeps production values (previews from before v4.5.1) (needs the GitHub App, or create it in the database) | | ✅ | Secret manager link | the app loads variables from the secret manager token | diff --git a/openapi.json b/openapi.json index fa3d83c6c5..6c318c2156 100644 --- a/openapi.json +++ b/openapi.json @@ -16268,6 +16268,11 @@ "default": 300, "maximum": 36000 }, + "use_shell": { + "type": "boolean", + "description": "Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.", + "default": true + }, "enabled": { "type": "boolean", "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", @@ -16425,6 +16430,11 @@ "default": 300, "maximum": 36000 }, + "use_shell": { + "type": "boolean", + "description": "Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.", + "default": true + }, "enabled": { "type": "boolean", "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", @@ -16622,6 +16632,11 @@ "default": 300, "maximum": 36000 }, + "use_shell": { + "type": "boolean", + "description": "Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.", + "default": true + }, "enabled": { "type": "boolean", "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", @@ -16779,6 +16794,11 @@ "default": 300, "maximum": 36000 }, + "use_shell": { + "type": "boolean", + "description": "Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.", + "default": true + }, "enabled": { "type": "boolean", "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", @@ -27638,6 +27658,10 @@ "type": "integer", "description": "The timeout of the scheduled task in seconds." }, + "use_shell": { + "type": "boolean", + "description": "Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects." + }, "consecutive_failures": { "type": "integer", "description": "The number of failed runs since the last successful run." diff --git a/openapi.yaml b/openapi.yaml index baed9a27ea..94b03a9c2c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10377,6 +10377,10 @@ paths: description: 'The timeout of the scheduled task in seconds.' default: 300 maximum: 36000 + use_shell: + type: boolean + description: 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.' + default: true enabled: type: boolean description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' @@ -10484,6 +10488,10 @@ paths: description: 'The timeout of the scheduled task in seconds.' default: 300 maximum: 36000 + use_shell: + type: boolean + description: 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.' + default: true enabled: type: boolean description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' @@ -10619,6 +10627,10 @@ paths: description: 'The timeout of the scheduled task in seconds.' default: 300 maximum: 36000 + use_shell: + type: boolean + description: 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.' + default: true enabled: type: boolean description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' @@ -10726,6 +10738,10 @@ paths: description: 'The timeout of the scheduled task in seconds.' default: 300 maximum: 36000 + use_shell: + type: boolean + description: 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.' + default: true enabled: type: boolean description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' @@ -17673,6 +17689,9 @@ components: timeout: type: integer description: 'The timeout of the scheduled task in seconds.' + use_shell: + type: boolean + description: 'Run the command with `sh -c` in the container. Set it to false for containers without a shell: the command is then split into arguments and run directly, without shell features such as variables, pipes, or redirects.' consecutive_failures: type: integer description: 'The number of failed runs since the last successful run.' diff --git a/resources/views/livewire/project/shared/scheduled-task/add.blade.php b/resources/views/livewire/project/shared/scheduled-task/add.blade.php index dee7f3731c..13db10094e 100644 --- a/resources/views/livewire/project/shared/scheduled-task/add.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/add.blade.php @@ -7,6 +7,8 @@ +
+
+ +
diff --git a/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php b/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php index 0f571fb49e..9436f3e006 100644 --- a/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php +++ b/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php @@ -192,3 +192,33 @@ describe('update', function () { expect($task->fresh()->command)->toBe('id'); }); }); + +test('a task can be created and updated to run without a shell', function () { + $headers = scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']); + + $created = $this->withHeaders($headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks", [ + 'name' => 'export', + 'command' => '/surreal export', + 'frequency' => 'daily', + 'use_shell' => false, + ]) + ->assertCreated() + ->assertJsonPath('use_shell', false); + $task = ScheduledTask::where('uuid', $created->json('uuid'))->sole(); + expect($task->use_shell)->toBeFalse(); + + $this->withHeaders($headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}", ['use_shell' => true]) + ->assertOk(); + expect($task->refresh()->use_shell)->toBeTrue(); + + $default = $this->withHeaders($headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks", [ + 'name' => 'cleanup', + 'command' => 'echo done', + 'frequency' => 'daily', + ]) + ->assertCreated(); + expect(ScheduledTask::where('uuid', $default->json('uuid'))->sole()->use_shell)->toBeTrue(); +}); diff --git a/tests/Feature/ContainerNameShellQuotingTest.php b/tests/Feature/ContainerNameShellQuotingTest.php index db32576d83..fb4ce278f1 100644 --- a/tests/Feature/ContainerNameShellQuotingTest.php +++ b/tests/Feature/ContainerNameShellQuotingTest.php @@ -149,6 +149,25 @@ test('a scheduled task in a service quotes the container name', function (bool $ expect($task->executions()->sole()->status)->toBe('success'); })->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); +test('a scheduled task without a shell runs the command arguments directly', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'service_id' => $this->service->id, + 'container' => QUOTING_HOSTILE_NAME, + 'command' => '/surreal export --conn "http://localhost:8000" $HOME;id', + 'use_shell' => false, + ]); + + (new ScheduledTaskJob($task))->handle(); + + quotingAssertCommandRan("{$sudo}docker exec {$this->containerName} '/surreal' 'export' '--conn' 'http://localhost:8000' '\$HOME;id' 2>&1 |"); + quotingAssertNoUnquotedName(); + expect($task->executions()->sole()->status)->toBe('success'); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + /** * `docker ps` returns one container for the application. */ diff --git a/tests/Feature/ScheduledTaskContainerCommandTest.php b/tests/Feature/ScheduledTaskContainerCommandTest.php new file mode 100644 index 0000000000..c262f685b6 --- /dev/null +++ b/tests/Feature/ScheduledTaskContainerCommandTest.php @@ -0,0 +1,32 @@ + $command, 'use_shell' => $useShell]))->containerCommand(); +} + +test('a task with a shell runs the command with sh -c', function () { + expect(containerCommandFor("echo 'hi' | wc -c", true))->toBe("sh -c 'echo '\\''hi'\\'' | wc -c'"); +}); + +test('a task without a shell splits the command into quoted arguments', function (string $command, string $expected) { + expect(containerCommandFor($command, false))->toBe($expected); +})->with([ + 'plain words' => ['/surreal export --user root', "'/surreal' 'export' '--user' 'root'"], + 'single quotes keep everything' => ["echo 'a \"b\" \$X'", "'echo' 'a \"b\" \$X'"], + 'double quotes with escapes' => ['echo "a \\"b\\" \\\\ \\n"', "'echo' 'a \"b\" \\ \\n'"], + 'quotes inside a word' => ['--name="a b"c', "'--name=a bc'"], + 'backslash outside quotes' => ['echo a\\ b', "'echo' 'a b'"], + 'empty argument' => ["cmd '' x", "'cmd' '' 'x'"], + 'line continuation' => ["cmd \\\n --flag", "'cmd' '--flag'"], + 'no expansion' => ['echo $HOME `id` $(id); rm *', "'echo' '\$HOME' '`id`' '\$(id);' 'rm' '*'"], +]); + +test('a task without a shell rejects a command that it cannot split', function (string $command, string $message) { + expect(fn () => containerCommandFor($command, false))->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'unclosed quote' => ['echo "hello', 'The command has a quote that is not closed.'], + 'only spaces' => [' ', 'The command is empty.'], +]); diff --git a/tests/Feature/ScheduledTaskModalTest.php b/tests/Feature/ScheduledTaskModalTest.php index 5c73bdc053..4aa5a44fc9 100644 --- a/tests/Feature/ScheduledTaskModalTest.php +++ b/tests/Feature/ScheduledTaskModalTest.php @@ -1,5 +1,6 @@ $this->task->uuid, ])); }); + +test('the run in a shell setting is saved when a task is added and edited', function () { + Livewire::test(Add::class, ['id' => (string) $this->application->id, 'type' => 'application', 'containerNames' => collect(), 'resource' => $this->application]) + ->set('name', 'export') + ->set('command', '/surreal export') + ->set('frequency', 'daily') + ->set('useShell', false) + ->call('submit') + ->assertHasNoErrors() + ->assertSet('useShell', true); + expect(ScheduledTask::where('name', 'export')->sole()->use_shell)->toBeFalse(); + + Livewire::test(Show::class, ['resource' => $this->application, 'task' => $this->task]) + ->assertSet('useShell', true) + ->set('useShell', false) + ->call('submit') + ->assertHasNoErrors(); + expect($this->task->refresh()->use_shell)->toBeFalse(); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 34e5e03606..ae8dabb1e8 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -191,6 +191,7 @@ beforeEach(function () { 'service_id' => $this->service->id, 'team_id' => $this->team->id, 'enabled' => true, + 'use_shell' => false, ]); $this->exporter = app(ServerTransferExporter::class); @@ -236,7 +237,8 @@ test('export includes server applications databases and secrets in plaintext', f $service = $environment['services'][0]; expect($service['uuid'])->toBe($this->service->uuid) ->and($service['scheduled_tasks'])->toHaveCount(1) - ->and($service['scheduled_tasks'][0]['name'])->toBe('service-ping'); + ->and($service['scheduled_tasks'][0]['name'])->toBe('service-ping') + ->and($service['scheduled_tasks'][0]['use_shell'])->toBeFalse(); }); test('export refuses localhost coolify host', function () { @@ -335,7 +337,8 @@ test('round trip import preserves uuids secrets and related resources after sour expect($serviceTask)->not->toBeNull() ->and($serviceTask->service_id)->toBe($service->id) ->and($serviceTask->name)->toBe('service-ping') - ->and($serviceTask->command)->toBe('echo ping'); + ->and($serviceTask->command)->toBe('echo ping') + ->and($serviceTask->use_shell)->toBeFalse(); $shared = SharedEnvironmentVariable::query() ->where('server_id', $server->id)