['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'], 'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'], 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'], 'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'], 'command' => ['type' => 'string', 'description' => 'The command to execute.'], '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.'], 'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'], 'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'], ], )] class ScheduledTask extends BaseModel { use HasFactory; use HasSafeStringAttribute; protected $fillable = [ 'uuid', 'enabled', 'name', 'command', 'frequency', 'container', 'timeout', 'use_shell', 'team_id', 'application_id', 'service_id', ]; /** * A task is disabled automatically only when it failed this many times in a row... */ public const AUTO_DISABLE_AFTER_FAILURES = 10; /** * ...and its first failure is this old, so a short outage does not disable a task that runs often. */ public const AUTO_DISABLE_AFTER_HOURS = 24; protected static function booted(): void { // A task that is enabled again starts a new failure streak, and its next failure notifies at once. static::saving(function (ScheduledTask $task) { if ($task->isDirty('enabled') && $task->enabled) { $task->consecutive_failures = 0; $task->failing_since = null; $task->disabled_reason = null; } }); static::saved(function (ScheduledTask $task) { if ($task->wasChanged('enabled') && $task->enabled) { NotificationThrottle::release($task, TaskFailed::class); } }); } public static function ownedByCurrentTeamAPI(int $teamId) { return static::where('team_id', $teamId)->orderBy('created_at', 'desc'); } protected function casts(): array { return [ 'enabled' => 'boolean', 'timeout' => 'integer', 'use_shell' => 'boolean', 'consecutive_failures' => 'integer', 'failing_since' => 'datetime', ]; } /** * Count a failed run. Disables the task when it failed too often for too long. * * @return bool True when this failure disabled the task. */ public function recordFailure(): bool { $this->refresh(); $this->consecutive_failures++; $this->failing_since ??= now(); $disable = $this->enabled && $this->consecutive_failures >= self::AUTO_DISABLE_AFTER_FAILURES && $this->failing_since->lte(now()->subHours(self::AUTO_DISABLE_AFTER_HOURS)); if ($disable) { $this->enabled = false; $this->disabled_reason = "Disabled automatically after {$this->consecutive_failures} failed runs in a row since {$this->failing_since->toDateTimeString()} UTC."; } $this->save(); return $disable; } /** * End the failure streak after a successful run. * * @return bool True when the team was notified about the failure, so it must be told that the task works again. */ public function recordSuccess(): bool { if ($this->consecutive_failures > 0 || $this->failing_since !== null) { $this->forceFill(['consecutive_failures' => 0, 'failing_since' => null])->save(); } 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; if (! $resource) { return null; } $type = $resource instanceof Application ? 'application' : 'service'; return base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$this->uuid}"; } public function service() { return $this->belongsTo(Service::class); } public function application() { return $this->belongsTo(Application::class); } public function latest_log(): HasOne { return $this->hasOne(ScheduledTaskExecution::class)->latest(); } public function executions(): HasMany { // Last execution first return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc'); } public function server(): ?Server { if ($this->application) { return $this->application->destination?->server; } if ($this->service) { return $this->service->destination?->server; } return null; } }