From c1ee0ac4f86b1911c8d0b75200dcf8213ece9699 Mon Sep 17 00:00:00 2001
From: Dane Everitt <dane@daneeveritt.com>
Date: Wed, 14 Oct 2020 20:38:59 -0700
Subject: [PATCH] Add support for executing a scheduled task right now

---
 .../ScheduleRepositoryInterface.php           | 10 ---
 .../Api/Client/Servers/ScheduleController.php | 37 +++++++-
 .../Schedules/TriggerScheduleRequest.php      | 25 ++++++
 app/Jobs/Schedule/RunTaskJob.php              |  4 +-
 app/Models/Schedule.php                       | 16 ++++
 .../Eloquent/ScheduleRepository.php           | 17 ----
 .../Schedules/ProcessScheduleService.php      | 57 +++++--------
 ...verSchedules.tsx => getServerSchedules.ts} |  0
 .../schedules/triggerScheduleExecution.ts     |  4 +
 .../server/schedules/RunScheduleButton.tsx    | 48 +++++++++++
 .../schedules/ScheduleEditContainer.tsx       | 11 ++-
 routes/api-client.php                         |  1 +
 .../Schedules/ProcessScheduleServiceTest.php  | 85 -------------------
 13 files changed, 158 insertions(+), 157 deletions(-)
 create mode 100644 app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php
 rename resources/scripts/api/server/schedules/{getServerSchedules.tsx => getServerSchedules.ts} (100%)
 create mode 100644 resources/scripts/api/server/schedules/triggerScheduleExecution.ts
 create mode 100644 resources/scripts/components/server/schedules/RunScheduleButton.tsx
 delete mode 100644 tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php

diff --git a/app/Contracts/Repository/ScheduleRepositoryInterface.php b/app/Contracts/Repository/ScheduleRepositoryInterface.php
index 4f340601..32650bdc 100644
--- a/app/Contracts/Repository/ScheduleRepositoryInterface.php
+++ b/app/Contracts/Repository/ScheduleRepositoryInterface.php
@@ -15,16 +15,6 @@ interface ScheduleRepositoryInterface extends RepositoryInterface
      */
     public function findServerSchedules(int $server): Collection;
 
-    /**
-     * Load the tasks relationship onto the Schedule module if they are not
-     * already present.
-     *
-     * @param \Pterodactyl\Models\Schedule $schedule
-     * @param bool $refresh
-     * @return \Pterodactyl\Models\Schedule
-     */
-    public function loadTasks(Schedule $schedule, bool $refresh = false): Schedule;
-
     /**
      * Return a schedule model with all of the associated tasks as a relationship.
      *
diff --git a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php
index d35b597e..a9310abd 100644
--- a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php
+++ b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php
@@ -10,15 +10,19 @@ use Pterodactyl\Models\Server;
 use Pterodactyl\Models\Schedule;
 use Illuminate\Http\JsonResponse;
 use Pterodactyl\Helpers\Utilities;
+use Pterodactyl\Jobs\Schedule\RunTaskJob;
 use Pterodactyl\Exceptions\DisplayException;
 use Pterodactyl\Repositories\Eloquent\ScheduleRepository;
+use Pterodactyl\Services\Schedules\ProcessScheduleService;
 use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
 use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest;
 use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest;
 use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest;
 use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest;
+use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest;
 
 class ScheduleController extends ClientApiController
 {
@@ -27,16 +31,23 @@ class ScheduleController extends ClientApiController
      */
     private $repository;
 
+    /**
+     * @var \Pterodactyl\Services\Schedules\ProcessScheduleService
+     */
+    private $service;
+
     /**
      * ScheduleController constructor.
      *
      * @param \Pterodactyl\Repositories\Eloquent\ScheduleRepository $repository
+     * @param \Pterodactyl\Services\Schedules\ProcessScheduleService $service
      */
-    public function __construct(ScheduleRepository $repository)
+    public function __construct(ScheduleRepository $repository, ProcessScheduleService $service)
     {
         parent::__construct();
 
         $this->repository = $repository;
+        $this->service = $service;
     }
 
     /**
@@ -147,6 +158,30 @@ class ScheduleController extends ClientApiController
             ->toArray();
     }
 
+    /**
+     * Executes a given schedule immediately rather than waiting on it's normally scheduled time
+     * to pass. This does not care about the schedule state.
+     *
+     * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest $request
+     * @param \Pterodactyl\Models\Server $server
+     * @param \Pterodactyl\Models\Schedule $schedule
+     * @return \Illuminate\Http\JsonResponse
+     *
+     * @throws \Throwable
+     */
+    public function execute(TriggerScheduleRequest $request, Server $server, Schedule $schedule)
+    {
+        if (!$schedule->is_active) {
+            throw new BadRequestHttpException(
+                'Cannot trigger schedule exection for a schedule that is not currently active.'
+            );
+        }
+
+        $this->service->handle($schedule, true);
+
+        return new JsonResponse([], JsonResponse::HTTP_ACCEPTED);
+    }
+
     /**
      * Deletes a schedule and it's associated tasks.
      *
diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php
new file mode 100644
index 00000000..7651b741
--- /dev/null
+++ b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules;
+
+use Pterodactyl\Models\Permission;
+use Illuminate\Foundation\Http\FormRequest;
+
+class TriggerScheduleRequest extends FormRequest
+{
+    /**
+     * @return string
+     */
+    public function permission(): string
+    {
+        return Permission::ACTION_SCHEDULE_UPDATE;
+    }
+
+    /**
+     * @return array
+     */
+    public function rules()
+    {
+        return [];
+    }
+}
diff --git a/app/Jobs/Schedule/RunTaskJob.php b/app/Jobs/Schedule/RunTaskJob.php
index bab1d61d..fe44058a 100644
--- a/app/Jobs/Schedule/RunTaskJob.php
+++ b/app/Jobs/Schedule/RunTaskJob.php
@@ -42,15 +42,13 @@ class RunTaskJob extends Job implements ShouldQueue
      * @param \Pterodactyl\Repositories\Wings\DaemonCommandRepository $commandRepository
      * @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService
      * @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository
-     * @param \Pterodactyl\Repositories\Eloquent\TaskRepository $taskRepository
      *
      * @throws \Throwable
      */
     public function handle(
         DaemonCommandRepository $commandRepository,
         InitiateBackupService $backupService,
-        DaemonPowerRepository $powerRepository,
-        TaskRepository $taskRepository
+        DaemonPowerRepository $powerRepository
     ) {
         // Do not process a task that is not set to active.
         if (! $this->task->schedule->is_active) {
diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php
index d737edd2..de047563 100644
--- a/app/Models/Schedule.php
+++ b/app/Models/Schedule.php
@@ -2,6 +2,8 @@
 
 namespace Pterodactyl\Models;
 
+use Cron\CronExpression;
+use Carbon\CarbonImmutable;
 use Illuminate\Container\Container;
 use Pterodactyl\Contracts\Extensions\HashidsInterface;
 
@@ -114,6 +116,20 @@ class Schedule extends Model
         'next_run_at' => 'nullable|date',
     ];
 
+    /**
+     * Returns the schedule's execution crontab entry as a string.
+     *
+     * @return \Carbon\CarbonImmutable
+     */
+    public function getNextRunDate()
+    {
+        $formatted = sprintf('%s %s %s * %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_day_of_week);
+
+        return CarbonImmutable::createFromTimestamp(
+            CronExpression::factory($formatted)->getNextRunDate()->getTimestamp()
+        );
+    }
+
     /**
      * Return a hashid encoded string to represent the ID of the schedule.
      *
diff --git a/app/Repositories/Eloquent/ScheduleRepository.php b/app/Repositories/Eloquent/ScheduleRepository.php
index 389d0672..030939da 100644
--- a/app/Repositories/Eloquent/ScheduleRepository.php
+++ b/app/Repositories/Eloquent/ScheduleRepository.php
@@ -31,23 +31,6 @@ class ScheduleRepository extends EloquentRepository implements ScheduleRepositor
         return $this->getBuilder()->withCount('tasks')->where('server_id', '=', $server)->get($this->getColumns());
     }
 
-    /**
-     * Load the tasks relationship onto the Schedule module if they are not
-     * already present.
-     *
-     * @param \Pterodactyl\Models\Schedule $schedule
-     * @param bool $refresh
-     * @return \Pterodactyl\Models\Schedule
-     */
-    public function loadTasks(Schedule $schedule, bool $refresh = false): Schedule
-    {
-        if (! $schedule->relationLoaded('tasks') || $refresh) {
-            $schedule->load('tasks');
-        }
-
-        return $schedule;
-    }
-
     /**
      * Return a schedule model with all of the associated tasks as a relationship.
      *
diff --git a/app/Services/Schedules/ProcessScheduleService.php b/app/Services/Schedules/ProcessScheduleService.php
index 3fa3604a..5d4ad60c 100644
--- a/app/Services/Schedules/ProcessScheduleService.php
+++ b/app/Services/Schedules/ProcessScheduleService.php
@@ -2,12 +2,10 @@
 
 namespace Pterodactyl\Services\Schedules;
 
-use Cron\CronExpression;
 use Pterodactyl\Models\Schedule;
 use Illuminate\Contracts\Bus\Dispatcher;
 use Pterodactyl\Jobs\Schedule\RunTaskJob;
-use Pterodactyl\Contracts\Repository\TaskRepositoryInterface;
-use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface;
+use Illuminate\Database\ConnectionInterface;
 
 class ProcessScheduleService
 {
@@ -17,62 +15,45 @@ class ProcessScheduleService
     private $dispatcher;
 
     /**
-     * @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface
+     * @var \Illuminate\Database\ConnectionInterface
      */
-    private $scheduleRepository;
-
-    /**
-     * @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface
-     */
-    private $taskRepository;
+    private $connection;
 
     /**
      * ProcessScheduleService constructor.
      *
+     * @param \Illuminate\Database\ConnectionInterface $connection
      * @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher
-     * @param \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface $scheduleRepository
-     * @param \Pterodactyl\Contracts\Repository\TaskRepositoryInterface $taskRepository
      */
-    public function __construct(
-        Dispatcher $dispatcher,
-        ScheduleRepositoryInterface $scheduleRepository,
-        TaskRepositoryInterface $taskRepository
-    ) {
+    public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher)
+    {
         $this->dispatcher = $dispatcher;
-        $this->scheduleRepository = $scheduleRepository;
-        $this->taskRepository = $taskRepository;
+        $this->connection = $connection;
     }
 
     /**
      * Process a schedule and push the first task onto the queue worker.
      *
      * @param \Pterodactyl\Models\Schedule $schedule
+     * @param bool $now
      *
-     * @throws \Pterodactyl\Exceptions\Model\DataValidationException
-     * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
+     * @throws \Throwable
      */
-    public function handle(Schedule $schedule)
+    public function handle(Schedule $schedule, bool $now = false)
     {
-        $this->scheduleRepository->loadTasks($schedule);
-
         /** @var \Pterodactyl\Models\Task $task */
-        $task = $schedule->getRelation('tasks')->where('sequence_id', 1)->first();
+        $task = $schedule->tasks()->where('sequence_id', 1)->firstOrFail();
 
-        $formattedCron = sprintf('%s %s %s * %s',
-            $schedule->cron_minute,
-            $schedule->cron_hour,
-            $schedule->cron_day_of_month,
-            $schedule->cron_day_of_week
-        );
+        $this->connection->transaction(function () use ($schedule, $task) {
+            $schedule->forceFill([
+                'is_processing' => true,
+                'next_run_at' => $schedule->getNextRunDate(),
+            ])->saveOrFail();
 
-        $this->scheduleRepository->update($schedule->id, [
-            'is_processing' => true,
-            'next_run_at' => CronExpression::factory($formattedCron)->getNextRunDate(),
-        ]);
+            $task->update(['is_queued' => true]);
+        });
 
-        $this->taskRepository->update($task->id, ['is_queued' => true]);
-
-        $this->dispatcher->dispatch(
+        $this->dispatcher->{$now ? 'dispatchNow' : 'dispatch'}(
             (new RunTaskJob($task))->delay($task->time_offset)
         );
     }
diff --git a/resources/scripts/api/server/schedules/getServerSchedules.tsx b/resources/scripts/api/server/schedules/getServerSchedules.ts
similarity index 100%
rename from resources/scripts/api/server/schedules/getServerSchedules.tsx
rename to resources/scripts/api/server/schedules/getServerSchedules.ts
diff --git a/resources/scripts/api/server/schedules/triggerScheduleExecution.ts b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts
new file mode 100644
index 00000000..92f7a589
--- /dev/null
+++ b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts
@@ -0,0 +1,4 @@
+import http from '@/api/http';
+
+export default async (server: string, schedule: number): Promise<void> =>
+    await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`);
diff --git a/resources/scripts/components/server/schedules/RunScheduleButton.tsx b/resources/scripts/components/server/schedules/RunScheduleButton.tsx
new file mode 100644
index 00000000..96424b06
--- /dev/null
+++ b/resources/scripts/components/server/schedules/RunScheduleButton.tsx
@@ -0,0 +1,48 @@
+import React, { useCallback, useState } from 'react';
+import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
+import tw from 'twin.macro';
+import Button from '@/components/elements/Button';
+import triggerScheduleExecution from '@/api/server/schedules/triggerScheduleExecution';
+import { ServerContext } from '@/state/server';
+import useFlash from '@/plugins/useFlash';
+import { Schedule } from '@/api/server/schedules/getServerSchedules';
+
+const RunScheduleButton = ({ schedule }: { schedule: Schedule }) => {
+    const [ loading, setLoading ] = useState(false);
+    const { clearFlashes, clearAndAddHttpError } = useFlash();
+
+    const id = ServerContext.useStoreState(state => state.server.data!.id);
+    const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
+
+    const onTriggerExecute = useCallback(() => {
+        clearFlashes('schedule');
+        setLoading(true);
+        triggerScheduleExecution(id, schedule.id)
+            .then(() => {
+                setLoading(false);
+                appendSchedule({ ...schedule, isProcessing: true });
+            })
+            .catch(error => {
+                console.error(error);
+                clearAndAddHttpError({ error, key: 'schedules' });
+            })
+            .then(() => setLoading(false));
+    }, []);
+
+    return (
+        <>
+            <SpinnerOverlay visible={loading} size={'large'}/>
+            <Button
+                isSecondary
+                color={'grey'}
+                css={tw`flex-1 sm:flex-none border-transparent`}
+                disabled={schedule.isProcessing}
+                onClick={onTriggerExecute}
+            >
+                Run Now
+            </Button>
+        </>
+    );
+};
+
+export default RunScheduleButton;
diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx
index aea4778a..5b226584 100644
--- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx
+++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx
@@ -4,7 +4,6 @@ import { Schedule } from '@/api/server/schedules/getServerSchedules';
 import getServerSchedule from '@/api/server/schedules/getServerSchedule';
 import Spinner from '@/components/elements/Spinner';
 import FlashMessageRender from '@/components/FlashMessageRender';
-import { httpErrorToHuman } from '@/api/http';
 import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
 import NewTaskButton from '@/components/server/schedules/NewTaskButton';
 import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
@@ -18,6 +17,7 @@ import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
 import isEqual from 'react-fast-compare';
 import { format } from 'date-fns';
 import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
+import RunScheduleButton from '@/components/server/schedules/RunScheduleButton';
 
 interface Params {
     id: string;
@@ -49,7 +49,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
     const id = ServerContext.useStoreState(state => state.server.data!.id);
     const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
 
-    const { clearFlashes, addError } = useFlash();
+    const { clearFlashes, clearAndAddHttpError } = useFlash();
     const [ isLoading, setIsLoading ] = useState(true);
     const [ showEditModal, setShowEditModal ] = useState(false);
 
@@ -68,7 +68,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
             .then(schedule => appendSchedule(schedule))
             .catch(error => {
                 console.error(error);
-                addError({ message: httpErrorToHuman(error), key: 'schedules' });
+                clearAndAddHttpError({ error, key: 'schedules' });
             })
             .then(() => setIsLoading(false));
     }, [ match ]);
@@ -146,6 +146,11 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
                                 onDeleted={() => history.push(`/server/${id}/schedules`)}
                             />
                         </Can>
+                        {schedule.isActive &&
+                        <Can action={'schedule.update'}>
+                            <RunScheduleButton schedule={schedule}/>
+                        </Can>
+                        }
                     </div>
                 </>
             }
diff --git a/routes/api-client.php b/routes/api-client.php
index 51a2f0de..fa045501 100644
--- a/routes/api-client.php
+++ b/routes/api-client.php
@@ -72,6 +72,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
         Route::post('/', 'Servers\ScheduleController@store');
         Route::get('/{schedule}', 'Servers\ScheduleController@view');
         Route::post('/{schedule}', 'Servers\ScheduleController@update');
+        Route::post('/{schedule}/execute', 'Servers\ScheduleController@execute');
         Route::delete('/{schedule}', 'Servers\ScheduleController@delete');
 
         Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store');
diff --git a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php b/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php
deleted file mode 100644
index 01bbac14..00000000
--- a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-
-namespace Tests\Unit\Services\Schedules;
-
-use Mockery as m;
-use Tests\TestCase;
-use Cron\CronExpression;
-use Pterodactyl\Models\Task;
-use Pterodactyl\Models\Schedule;
-use Illuminate\Contracts\Bus\Dispatcher;
-use Pterodactyl\Jobs\Schedule\RunTaskJob;
-use Pterodactyl\Services\Schedules\ProcessScheduleService;
-use Pterodactyl\Contracts\Repository\TaskRepositoryInterface;
-use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface;
-
-class ProcessScheduleServiceTest extends TestCase
-{
-    /**
-     * @var \Illuminate\Contracts\Bus\Dispatcher|\Mockery\Mock
-     */
-    private $dispatcher;
-
-    /**
-     * @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface|\Mockery\Mock
-     */
-    private $scheduleRepository;
-
-    /**
-     * @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface|\Mockery\Mock
-     */
-    private $taskRepository;
-
-    /**
-     * Setup tests.
-     */
-    public function setUp(): void
-    {
-        parent::setUp();
-
-        $this->dispatcher = m::mock(Dispatcher::class);
-        $this->scheduleRepository = m::mock(ScheduleRepositoryInterface::class);
-        $this->taskRepository = m::mock(TaskRepositoryInterface::class);
-    }
-
-    /**
-     * Test that a schedule can be updated and first task set to run.
-     */
-    public function testScheduleIsUpdatedAndRun()
-    {
-        $model = factory(Schedule::class)->make(['id' => 123]);
-        $model->setRelation('tasks', collect([$task = factory(Task::class)->make([
-            'sequence_id' => 1,
-        ])]));
-
-        $this->scheduleRepository->shouldReceive('loadTasks')->with($model)->once()->andReturn($model);
-
-        $formatted = sprintf('%s %s %s * %s', $model->cron_minute, $model->cron_hour, $model->cron_day_of_month, $model->cron_day_of_week);
-        $this->scheduleRepository->shouldReceive('update')->with($model->id, [
-            'is_processing' => true,
-            'next_run_at' => CronExpression::factory($formatted)->getNextRunDate(),
-        ]);
-
-        $this->taskRepository->shouldReceive('update')->with($task->id, ['is_queued' => true])->once();
-
-        $this->dispatcher->shouldReceive('dispatch')->with(m::on(function ($class) use ($model, $task) {
-            $this->assertInstanceOf(RunTaskJob::class, $class);
-            $this->assertSame($task->time_offset, $class->delay);
-            $this->assertSame($task->id, $class->task->id);
-
-            return true;
-        }))->once();
-
-        $this->getService()->handle($model);
-    }
-
-    /**
-     * Return an instance of the service for testing purposes.
-     *
-     * @return \Pterodactyl\Services\Schedules\ProcessScheduleService
-     */
-    private function getService(): ProcessScheduleService
-    {
-        return new ProcessScheduleService($this->dispatcher, $this->scheduleRepository, $this->taskRepository);
-    }
-}