diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2a8e857e2..267572b39 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,12 +5,14 @@ namespace App\Console; use App\Jobs\CheckLogDrainContainerJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\DatabaseBackupJob; +use App\Jobs\ScheduledTaskJob; use App\Jobs\InstanceAutoUpdateJob; use App\Jobs\ContainerStatusJob; use App\Jobs\PullHelperImageJob; use App\Jobs\ServerStatusJob; use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; @@ -30,6 +32,7 @@ class Kernel extends ConsoleKernel $this->check_resources($schedule); $this->check_scheduled_backups($schedule); $this->pull_helper_image($schedule); + $this->check_scheduled_tasks($schedule); } else { // Instance Jobs $schedule->command('horizon:snapshot')->everyFiveMinutes(); @@ -41,6 +44,7 @@ class Kernel extends ConsoleKernel $this->check_scheduled_backups($schedule); $this->check_resources($schedule); $this->pull_helper_image($schedule); + $this->check_scheduled_tasks($schedule); } } private function pull_helper_image($schedule) @@ -107,6 +111,32 @@ class Kernel extends ConsoleKernel } } + private function check_scheduled_tasks($schedule) { + $scheduled_tasks = ScheduledTask::all(); + if ($scheduled_tasks->isEmpty()) { + ray('no scheduled tasks'); + return; + } + foreach ($scheduled_tasks as $scheduled_task) { + $service = $scheduled_task->service()->get(); + $application = $scheduled_task->application()->get(); + + if (!$application && !$service) { + ray('application/service attached to scheduled task does not exist'); + $scheduled_task->delete(); + continue; + } + + if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) { + $scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency]; + } + $schedule->job(new ScheduledTaskJob( + task: $scheduled_task + ))->cron($scheduled_task->frequency)->onOneServer(); + } + + } + protected function commands(): void { $this->load(__DIR__ . '/Commands'); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php new file mode 100644 index 000000000..104b5f7e3 --- /dev/null +++ b/app/Jobs/ScheduledTaskJob.php @@ -0,0 +1,115 @@ +task = $task; + if ($service = $task->service()->first()) { + $this->resource = $service; + } else if ($application = $task->application()->first()) { + $this->resource = $application; + } + $this->team = Team::find($task->team_id); + } + + public function middleware(): array + { + return [new WithoutOverlapping($this->task->id)]; + } + + public function uniqueId(): int + { + return $this->task->id; + } + + public function handle(): void + { + try { + $this->task_log = ScheduledTaskExecution::create([ + 'scheduled_task_id' => $this->task->id, + ]); + + $this->server = $this->resource->destination->server; + + if ($this->resource->type() == 'application') { + $containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0); + if ($containers->count() > 0) { + $containers->each(function ($container) { + $this->containers[] = str_replace('/', '', $container['Names']); + }); + } + } + elseif ($this->resource->type() == 'service') { + $this->resource->applications()->get()->each(function ($application) { + if (str(data_get($application, 'status'))->contains('running')) { + $this->containers[] = data_get($application, 'name') . '-' . data_get($this->resource, 'uuid'); + } + }); + } + + if (count($this->containers) == 0) { + throw new \Exception('ScheduledTaskJob failed: No containers running.'); + } + + if (count($this->containers) > 1 && empty($this->task->container)) { + throw new \Exception('ScheduledTaskJob failed: More than one container exists but no container name was provided.'); + } + + foreach ($this->containers as $containerName) { + if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container . '-' . $this->resource->uuid)) { + $cmd = 'sh -c "' . str_replace('"', '\"', $this->task->command) . '"'; + $exec = "docker exec {$containerName} {$cmd}"; + $this->task_output = instant_remote_process([$exec], $this->server, true); + $this->task_log->update([ + 'status' => 'success', + 'message' => $this->task_output, + ]); + return; + } + } + + // No valid container was found. + throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); + + } catch (\Throwable $e) { + if ($this->task_log) { + $this->task_log->update([ + 'status' => 'failed', + 'message' => $this->task_output ?? $e->getMessage(), + ]); + } + send_internal_notification('ScheduledTaskJob failed with: ' . $e->getMessage()); + throw $e; + } + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php new file mode 100644 index 000000000..3cc5428b8 --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -0,0 +1,58 @@ + 'clear']; + protected $rules = [ + 'name' => 'required|string', + 'command' => 'required|string', + 'frequency' => 'required|string', + 'container' => 'nullable|string', + ]; + protected $validationAttributes = [ + 'name' => 'name', + 'command' => 'command', + 'frequency' => 'frequency', + 'container' => 'container', + ]; + + public function mount() + { + $this->parameters = get_route_parameters(); + } + + public function submit() + { + $this->validate(); + $isValid = validate_cron_expression($this->frequency); + if (!$isValid) { + $this->dispatch('error', 'Invalid Cron / Human expression.'); + return; + } + $this->dispatch('saveScheduledTask', [ + 'name' => $this->name, + 'command' => $this->command, + 'frequency' => $this->frequency, + 'container' => $this->container, + ]); + $this->clear(); + } + + public function clear() + { + $this->name = ''; + $this->command = ''; + $this->frequency = ''; + $this->container = ''; + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/All.php b/app/Livewire/Project/Shared/ScheduledTask/All.php new file mode 100644 index 000000000..4a876e72a --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/All.php @@ -0,0 +1,56 @@ + 'submit']; + + public function mount() + { + $this->parameters = get_route_parameters(); + $this->modalId = new Cuid2(7); + } + public function refreshTasks() + { + $this->resource->refresh(); + } + + public function submit($data) + { + try { + $task = new ScheduledTask(); + $task->name = $data['name']; + $task->command = $data['command']; + $task->frequency = $data['frequency']; + $task->container = $data['container']; + $task->team_id = currentTeam()->id; + + switch ($this->resource->type()) { + case 'application': + $task->application_id = $this->resource->id; + break; + case 'standalone-postgresql': + $task->standalone_postgresql_id = $this->resource->id; + break; + case 'service': + $task->service_id = $this->resource->id; + break; + } + $task->save(); + $this->refreshTasks(); + $this->dispatch('success', 'Scheduled task added successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php new file mode 100644 index 000000000..9c1ec7cc5 --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -0,0 +1,27 @@ +selectedKey) { + $this->selectedKey = null; + return; + } + $this->selectedKey = $key; + } +} diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php new file mode 100644 index 000000000..23cb0e41a --- /dev/null +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -0,0 +1,72 @@ + 'required|string', + 'task.command' => 'required|string', + 'task.frequency' => 'required|string', + 'task.container' => 'nullable|string', + ]; + protected $validationAttributes = [ + 'name' => 'name', + 'command' => 'command', + 'frequency' => 'frequency', + 'container' => 'container', + ]; + + public function mount() + { + $this->parameters = get_route_parameters(); + + if (data_get($this->parameters, 'application_uuid')) { + $this->type = 'application'; + $this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + } else if (data_get($this->parameters, 'service_uuid')) { + $this->type = 'service'; + $this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + } + + $this->modalId = new Cuid2(7); + $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + } + + public function submit() + { + $this->validate(); + $this->task->save(); + $this->dispatch('success', 'Scheduled task updated successfully.'); + $this->dispatch('refreshTasks'); + } + + public function delete() + { + try { + $this->task->delete(); + + if ($this->type == 'application') { + return redirect()->route('project.application.configuration', $this->parameters); + } + else { + return redirect()->route('project.service.configuration', $this->parameters); + } + + } catch (\Exception $e) { + return handleError($e); + } + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index e0cba3764..c16adc7e6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -315,6 +315,11 @@ class Application extends BaseModel return $this->hasMany(EnvironmentVariable::class)->where('is_preview', true)->where('key', 'like', 'NIXPACKS_%'); } + public function scheduled_tasks(): HasMany + { + return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); + } + public function private_key() { return $this->belongsTo(PrivateKey::class); diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php new file mode 100644 index 000000000..2ff391c59 --- /dev/null +++ b/app/Models/ScheduledTask.php @@ -0,0 +1,28 @@ +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 + { + return $this->hasMany(ScheduledTaskExecution::class); + } +} diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php new file mode 100644 index 000000000..de13fefb0 --- /dev/null +++ b/app/Models/ScheduledTaskExecution.php @@ -0,0 +1,15 @@ +belongsTo(ScheduledTask::class); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index eb0d96670..7f71ff865 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -396,6 +396,10 @@ class Service extends BaseModel } return null; } + public function scheduled_tasks(): HasMany + { + return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); + } public function environment_variables(): HasMany { return $this->hasMany(EnvironmentVariable::class)->orderBy('key', 'asc'); diff --git a/database/migrations/2023_12_31_173041_create_scheduled_tasks_table.php b/database/migrations/2023_12_31_173041_create_scheduled_tasks_table.php new file mode 100644 index 000000000..c3c2e2f48 --- /dev/null +++ b/database/migrations/2023_12_31_173041_create_scheduled_tasks_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('uuid')->unique(); + $table->boolean('enabled')->default(true); + $table->string('name'); + $table->string('command'); + $table->string('frequency'); + $table->string('container')->nullable(); + $table->timestamps(); + + $table->foreignId('application_id')->nullable(); + $table->foreignId('service_id')->nullable(); + $table->foreignId('team_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_tasks'); + } +}; diff --git a/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php b/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php new file mode 100644 index 000000000..27ace08d4 --- /dev/null +++ b/database/migrations/2024_01_01_231053_create_scheduled_task_executions_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('uuid')->unique(); + $table->enum('status', ['success', 'failed', 'running'])->default('running'); + $table->longText('message')->nullable(); + $table->foreignId('scheduled_task_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_task_executions'); + } +}; diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 579f1cac1..59899584f 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -54,6 +54,9 @@ href="#">Resource Limits @endif + Scheduled Tasks + Danger Zone @@ -97,6 +100,9 @@
+
+ +
diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 15e9b9d21..b16a738b5 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -13,6 +13,19 @@ @click.prevent="activeTab = 'storages'; window.location.hash = 'storages'; if(window.location.search) window.location.search = ''" href="#">Storages + Environment + Variables + Scheduled Tasks + + Danger Zone + @if ( $serviceDatabase?->databaseType() === 'standalone-mysql' || $serviceDatabase?->databaseType() === 'standalone-postgresql' || @@ -56,6 +69,13 @@ + +
+ +
+
+ +
@endisset diff --git a/resources/views/livewire/project/shared/scheduled-task/add.blade.php b/resources/views/livewire/project/shared/scheduled-task/add.blade.php new file mode 100644 index 000000000..133c8cead --- /dev/null +++ b/resources/views/livewire/project/shared/scheduled-task/add.blade.php @@ -0,0 +1,15 @@ + + diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php new file mode 100644 index 000000000..b860e00b1 --- /dev/null +++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php @@ -0,0 +1,25 @@ +
+
+

Scheduled Tasks

+ + Add + +
+ +
+ @forelse($resource->scheduled_tasks as $task) + type() == 'application') + href="{{ route('project.application.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}"> + @elseif ($resource->type() == 'service') + href="{{ route('project.service.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}"> + @endif +
{{ $task->name }}
+
Frequency: {{ $task->frequency }}
+
Last run: {{ data_get($task->latest_log, 'status', 'No runs yet') }}
+
+ @empty +
No scheduled tasks configured.
+ @endforelse +
+
diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php new file mode 100644 index 000000000..9f0fd9208 --- /dev/null +++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php @@ -0,0 +1,27 @@ +
+ @forelse($executions as $execution) + data_get($execution, 'status') === 'success', + 'border-red-500' => data_get($execution, 'status') === 'failed', + ])> + @if (data_get($execution, 'status') === 'running') +
+ +
+ @endif +
Status: {{ data_get($execution, 'status') }}
+
Started At: {{ data_get($execution, 'created_at') }}
+ @if (data_get($execution, 'id') == $selectedKey) + @if (data_get($execution, 'message')) +
Output:
{{ data_get($execution, 'message') }}
+ @else +
No output was recorded for this execution.
+ @endif + @endif +
+ + @empty +
No executions found.
+ @endforelse +
diff --git a/resources/views/livewire/project/shared/scheduled-task/show.blade.php b/resources/views/livewire/project/shared/scheduled-task/show.blade.php new file mode 100644 index 000000000..cbeb971b1 --- /dev/null +++ b/resources/views/livewire/project/shared/scheduled-task/show.blade.php @@ -0,0 +1,42 @@ +
+ + +

Are you sure you want to delete this scheduled task ({{ $task->name }})?

+
+
+ +

Scheduled Task

+ @if ($type === 'application') + + @elseif ($type === 'service') + + @endif + +
+
+
+

Scheduled Task

+ + Save + + + + Delete + + +
+
+ + + + + + + +
+

Recent executions

+ +
+
diff --git a/routes/web.php b/routes/web.php index b25ee3819..8735cf872 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,6 +50,7 @@ use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\EnvironmentEdit; use App\Livewire\Project\Shared\ExecuteContainerCommand; use App\Livewire\Project\Shared\Logs; +use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Security\ApiTokens; use App\Livewire\Security\PrivateKey\Create as SecurityPrivateKeyCreate; @@ -139,6 +140,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show'); Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/command', ExecuteContainerCommand::class)->name('project.application.command'); + Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/{environment_name}/database/{database_uuid}')->group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); @@ -151,6 +153,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', ServiceConfiguration::class)->name('project.service.configuration'); Route::get('/{service_name}', ServiceIndex::class)->name('project.service.index'); Route::get('/command', ExecuteContainerCommand::class)->name('project.service.command'); + Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index');