Add support for executing a scheduled task right now

This commit is contained in:
Dane Everitt 2020-10-14 20:38:59 -07:00
parent f33d0b1d72
commit c1ee0ac4f8
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
13 changed files with 158 additions and 157 deletions

View file

@ -15,16 +15,6 @@ interface ScheduleRepositoryInterface extends RepositoryInterface
*/ */
public function findServerSchedules(int $server): Collection; 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. * Return a schedule model with all of the associated tasks as a relationship.
* *

View file

@ -10,15 +10,19 @@ use Pterodactyl\Models\Server;
use Pterodactyl\Models\Schedule; use Pterodactyl\Models\Schedule;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Helpers\Utilities; use Pterodactyl\Helpers\Utilities;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ScheduleRepository; use Pterodactyl\Repositories\Eloquent\ScheduleRepository;
use Pterodactyl\Services\Schedules\ProcessScheduleService;
use Pterodactyl\Transformers\Api\Client\ScheduleTransformer; use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 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\ViewScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest; 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\DeleteScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest;
class ScheduleController extends ClientApiController class ScheduleController extends ClientApiController
{ {
@ -27,16 +31,23 @@ class ScheduleController extends ClientApiController
*/ */
private $repository; private $repository;
/**
* @var \Pterodactyl\Services\Schedules\ProcessScheduleService
*/
private $service;
/** /**
* ScheduleController constructor. * ScheduleController constructor.
* *
* @param \Pterodactyl\Repositories\Eloquent\ScheduleRepository $repository * @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(); parent::__construct();
$this->repository = $repository; $this->repository = $repository;
$this->service = $service;
} }
/** /**
@ -147,6 +158,30 @@ class ScheduleController extends ClientApiController
->toArray(); ->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. * Deletes a schedule and it's associated tasks.
* *

View file

@ -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 [];
}
}

View file

@ -42,15 +42,13 @@ class RunTaskJob extends Job implements ShouldQueue
* @param \Pterodactyl\Repositories\Wings\DaemonCommandRepository $commandRepository * @param \Pterodactyl\Repositories\Wings\DaemonCommandRepository $commandRepository
* @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService * @param \Pterodactyl\Services\Backups\InitiateBackupService $backupService
* @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository * @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository
* @param \Pterodactyl\Repositories\Eloquent\TaskRepository $taskRepository
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function handle( public function handle(
DaemonCommandRepository $commandRepository, DaemonCommandRepository $commandRepository,
InitiateBackupService $backupService, InitiateBackupService $backupService,
DaemonPowerRepository $powerRepository, DaemonPowerRepository $powerRepository
TaskRepository $taskRepository
) { ) {
// Do not process a task that is not set to active. // Do not process a task that is not set to active.
if (! $this->task->schedule->is_active) { if (! $this->task->schedule->is_active) {

View file

@ -2,6 +2,8 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
use Cron\CronExpression;
use Carbon\CarbonImmutable;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Pterodactyl\Contracts\Extensions\HashidsInterface; use Pterodactyl\Contracts\Extensions\HashidsInterface;
@ -114,6 +116,20 @@ class Schedule extends Model
'next_run_at' => 'nullable|date', '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. * Return a hashid encoded string to represent the ID of the schedule.
* *

View file

@ -31,23 +31,6 @@ class ScheduleRepository extends EloquentRepository implements ScheduleRepositor
return $this->getBuilder()->withCount('tasks')->where('server_id', '=', $server)->get($this->getColumns()); 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. * Return a schedule model with all of the associated tasks as a relationship.
* *

View file

@ -2,12 +2,10 @@
namespace Pterodactyl\Services\Schedules; namespace Pterodactyl\Services\Schedules;
use Cron\CronExpression;
use Pterodactyl\Models\Schedule; use Pterodactyl\Models\Schedule;
use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Bus\Dispatcher;
use Pterodactyl\Jobs\Schedule\RunTaskJob; use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Pterodactyl\Contracts\Repository\TaskRepositoryInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface;
class ProcessScheduleService class ProcessScheduleService
{ {
@ -17,62 +15,45 @@ class ProcessScheduleService
private $dispatcher; private $dispatcher;
/** /**
* @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface * @var \Illuminate\Database\ConnectionInterface
*/ */
private $scheduleRepository; private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface
*/
private $taskRepository;
/** /**
* ProcessScheduleService constructor. * ProcessScheduleService constructor.
* *
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher * @param \Illuminate\Contracts\Bus\Dispatcher $dispatcher
* @param \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface $scheduleRepository
* @param \Pterodactyl\Contracts\Repository\TaskRepositoryInterface $taskRepository
*/ */
public function __construct( public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher)
Dispatcher $dispatcher, {
ScheduleRepositoryInterface $scheduleRepository,
TaskRepositoryInterface $taskRepository
) {
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->scheduleRepository = $scheduleRepository; $this->connection = $connection;
$this->taskRepository = $taskRepository;
} }
/** /**
* Process a schedule and push the first task onto the queue worker. * Process a schedule and push the first task onto the queue worker.
* *
* @param \Pterodactyl\Models\Schedule $schedule * @param \Pterodactyl\Models\Schedule $schedule
* @param bool $now
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Throwable
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function handle(Schedule $schedule) public function handle(Schedule $schedule, bool $now = false)
{ {
$this->scheduleRepository->loadTasks($schedule);
/** @var \Pterodactyl\Models\Task $task */ /** @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', $this->connection->transaction(function () use ($schedule, $task) {
$schedule->cron_minute, $schedule->forceFill([
$schedule->cron_hour, 'is_processing' => true,
$schedule->cron_day_of_month, 'next_run_at' => $schedule->getNextRunDate(),
$schedule->cron_day_of_week ])->saveOrFail();
);
$this->scheduleRepository->update($schedule->id, [ $task->update(['is_queued' => true]);
'is_processing' => true, });
'next_run_at' => CronExpression::factory($formattedCron)->getNextRunDate(),
]);
$this->taskRepository->update($task->id, ['is_queued' => true]); $this->dispatcher->{$now ? 'dispatchNow' : 'dispatch'}(
$this->dispatcher->dispatch(
(new RunTaskJob($task))->delay($task->time_offset) (new RunTaskJob($task))->delay($task->time_offset)
); );
} }

View file

@ -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`);

View file

@ -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;

View file

@ -4,7 +4,6 @@ import { Schedule } from '@/api/server/schedules/getServerSchedules';
import getServerSchedule from '@/api/server/schedules/getServerSchedule'; import getServerSchedule from '@/api/server/schedules/getServerSchedule';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; 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 isEqual from 'react-fast-compare';
import { format } from 'date-fns'; import { format } from 'date-fns';
import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow'; import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow';
import RunScheduleButton from '@/components/server/schedules/RunScheduleButton';
interface Params { interface Params {
id: string; id: string;
@ -49,7 +49,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
const id = ServerContext.useStoreState(state => state.server.data!.id); const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true); const [ isLoading, setIsLoading ] = useState(true);
const [ showEditModal, setShowEditModal ] = useState(false); const [ showEditModal, setShowEditModal ] = useState(false);
@ -68,7 +68,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
.then(schedule => appendSchedule(schedule)) .then(schedule => appendSchedule(schedule))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ message: httpErrorToHuman(error), key: 'schedules' }); clearAndAddHttpError({ error, key: 'schedules' });
}) })
.then(() => setIsLoading(false)); .then(() => setIsLoading(false));
}, [ match ]); }, [ match ]);
@ -146,6 +146,11 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
onDeleted={() => history.push(`/server/${id}/schedules`)} onDeleted={() => history.push(`/server/${id}/schedules`)}
/> />
</Can> </Can>
{schedule.isActive &&
<Can action={'schedule.update'}>
<RunScheduleButton schedule={schedule}/>
</Can>
}
</div> </div>
</> </>
} }

View file

@ -72,6 +72,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::post('/', 'Servers\ScheduleController@store'); Route::post('/', 'Servers\ScheduleController@store');
Route::get('/{schedule}', 'Servers\ScheduleController@view'); Route::get('/{schedule}', 'Servers\ScheduleController@view');
Route::post('/{schedule}', 'Servers\ScheduleController@update'); Route::post('/{schedule}', 'Servers\ScheduleController@update');
Route::post('/{schedule}/execute', 'Servers\ScheduleController@execute');
Route::delete('/{schedule}', 'Servers\ScheduleController@delete'); Route::delete('/{schedule}', 'Servers\ScheduleController@delete');
Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store'); Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store');

View file

@ -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);
}
}