mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0b94662e9
commit
31c3fcf50d
20 files changed
+301
-10
No files matched your search
@@ -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],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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 <container> ...` 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<string>
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_tasks', function (Blueprint $table) {
|
||||
$table->boolean('use_shell')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('scheduled_tasks', function (Blueprint $table) {
|
||||
$table->dropColumn('use_shell');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 |
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
</div>
|
||||
|
||||
<x-forms.input placeholder="php artisan schedule:run" id="command" label="Command" />
|
||||
<x-forms.checkbox id="useShell" label="Run in a shell"
|
||||
helper="Run the command with <span class='font-mono'>sh -c</span>, so you can use variables, pipes, and redirects. Turn it off for containers without a shell (distroless or scratch images): Coolify then splits the command into arguments and runs it directly." />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.input type="number" placeholder="300" id="timeout"
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
<x-forms.input canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)"
|
||||
placeholder="php artisan schedule:run" id="command" label="Command" required />
|
||||
</div>
|
||||
<div class="mt-2 md:w-1/2">
|
||||
<x-forms.checkbox canGate="update" :canResource="$resource" :disabled="!auth()->user()->can('update', $resource)"
|
||||
id="useShell" label="Run in a shell"
|
||||
helper="Run the command with <span class='font-mono'>sh -c</span>, so you can use variables, pipes, and redirects. Turn it off for containers without a shell (distroless or scratch images): Coolify then splits the command into arguments and runs it directly." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ScheduledTask;
|
||||
|
||||
function containerCommandFor(string $command, bool $useShell): string
|
||||
{
|
||||
return (new ScheduledTask(['command' => $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.'],
|
||||
]);
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Add;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\All;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Show;
|
||||
use App\Models\Application;
|
||||
@@ -100,3 +101,22 @@ test('old task page url redirects to the list with the modal open', function ()
|
||||
'task_uuid' => $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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user