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:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 22:19:37 +01:00
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],
],
),
+1 -2
View File
@@ -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 [];
+1
View File
@@ -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) {
+3 -3
View File
@@ -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.';
}
}
+95
View File
@@ -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');
});
}
};
+1 -1
View File
@@ -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 |
+24
View File
@@ -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."
+19
View File
@@ -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.'],
]);
+20
View File
@@ -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)