diff --git a/typo3/sysext/core/Classes/Console/CommandRegistry.php b/typo3/sysext/core/Classes/Console/CommandRegistry.php index ce79263708e87250b4d895c9c8ca3d0a171b8951..cbfc91a2aa68c1aa6e505f542c65737b1c6cee39 100644 --- a/typo3/sysext/core/Classes/Console/CommandRegistry.php +++ b/typo3/sysext/core/Classes/Console/CommandRegistry.php @@ -56,6 +56,26 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface } } + /** + * @param string $identifier + * @throws CommandNameAlreadyInUseException + * @throws UnknownCommandException + * @return Command + */ + public function getCommandByIdentifier(string $identifier): Command + { + $this->populateCommandsFromPackages(); + + if (!isset($this->commands[$identifier])) { + throw new UnknownCommandException( + sprintf('Command "%s" has not been registered.', $identifier), + 1510906768 + ); + } + + return $this->commands[$identifier] ?? null; + } + /** * Find all Configuration/Commands.php files of extensions and create a registry from it. * The file should return an array with a command key as key and the command description @@ -79,7 +99,12 @@ class CommandRegistry implements \IteratorAggregate, SingletonInterface foreach ($this->packageManager->getActivePackages() as $package) { $commandsOfExtension = $package->getPackagePath() . 'Configuration/Commands.php'; if (@is_file($commandsOfExtension)) { - $commands = require_once $commandsOfExtension; + /* + * We use require instead of require_once here because it eases the testability as require_once returns + * a boolean from the second execution on. As this class is a singleton, this require is only called + * once per request anyway. + */ + $commands = require $commandsOfExtension; if (is_array($commands)) { foreach ($commands as $commandName => $commandConfig) { if (array_key_exists($commandName, $this->commands)) { diff --git a/typo3/sysext/core/Classes/Console/UnknownCommandException.php b/typo3/sysext/core/Classes/Console/UnknownCommandException.php new file mode 100644 index 0000000000000000000000000000000000000000..e583bd7f3cbba4942cb28fa5050f51a6b6959b44 --- /dev/null +++ b/typo3/sysext/core/Classes/Console/UnknownCommandException.php @@ -0,0 +1,25 @@ +<?php +declare(strict_types=1); +namespace TYPO3\CMS\Core\Console; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use TYPO3\CMS\Core\Exception; + +/** + * Exception thrown when an unregistered command is asked for + */ +class UnknownCommandException extends Exception +{ +} diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-79462-IntroduceSchedulerTaskToExecuteConsoleCommand.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-79462-IntroduceSchedulerTaskToExecuteConsoleCommand.rst new file mode 100644 index 0000000000000000000000000000000000000000..cf8d855ceadbd90cd8d7c0e67083103ef2a450d3 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-79462-IntroduceSchedulerTaskToExecuteConsoleCommand.rst @@ -0,0 +1,23 @@ +.. include:: ../../Includes.txt + +===================================================================== +Feature: #79462 - Introduce scheduler task to execute console command +===================================================================== + +See :issue:`79462` + +Description +=========== + +A scheduler task has been introduced to execute (symfony) console commands. In the past this was +already possible for Extbase command controller commands but as the core migrates all command +controllers to native symfony commands, the scheduler needs to be able to execute them. + + +Impact +====== + +Symfony commands can be executed via the scheduler which provides a migration path away from +command controllers to native symfony commands. + +.. index:: CLI, NotScanned diff --git a/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php b/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php index e348d305ee621b06028b0d69d724371e940e80f6..a6f7cd8983c23466525a7d6547e81792099788f6 100644 --- a/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php +++ b/typo3/sysext/core/Tests/Unit/Console/CommandRegistryTest.php @@ -16,9 +16,11 @@ namespace TYPO3\CMS\Core\Tests\Unit\Console; */ use org\bovigo\vfs\vfsStream; +use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Command\Command; use TYPO3\CMS\Core\Console\CommandNameAlreadyInUseException; use TYPO3\CMS\Core\Console\CommandRegistry; +use TYPO3\CMS\Core\Console\UnknownCommandException; use TYPO3\CMS\Core\Package\PackageInterface; use TYPO3\CMS\Core\Package\PackageManager; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; @@ -113,4 +115,36 @@ class CommandRegistryTest extends UnitTestCase $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal()); iterator_to_array($commandRegistry); } + + /** + * @test + */ + public function getCommandByIdentifierReturnsRegisteredCommand() + { + /** @var PackageInterface|ObjectProphecy $package */ + $package = $this->prophesize(PackageInterface::class); + $package->getPackagePath()->willReturn($this->rootDirectory->getChild('package1')->url() . '/'); + $package->getPackageKey()->willReturn('package1'); + + $this->packageManagerProphecy->getActivePackages()->willReturn([$package->reveal()]); + + $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal()); + $command = $commandRegistry->getCommandByIdentifier('first:command'); + + $this->assertInstanceOf(Command::class, $command); + } + + /** + * @test + */ + public function throwsUnknowCommandExceptionIfUnregisteredCommandIsRequested() + { + $this->packageManagerProphecy->getActivePackages()->willReturn([]); + + $this->expectException(UnknownCommandException::class); + $this->expectExceptionCode(1510906768); + + $commandRegistry = new CommandRegistry($this->packageManagerProphecy->reveal()); + $commandRegistry->getCommandByIdentifier('foo'); + } } diff --git a/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandAdditionalFieldProvider.php b/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandAdditionalFieldProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..e2974504f768056cf4ae824ab71bae5299dff83f --- /dev/null +++ b/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandAdditionalFieldProvider.php @@ -0,0 +1,331 @@ +<?php +declare(strict_types=1); +namespace TYPO3\CMS\Scheduler\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; +use TYPO3\CMS\Core\Console\CommandRegistry; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Lang\LanguageService; +use TYPO3\CMS\Scheduler\AdditionalFieldProviderInterface; +use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController; +use TYPO3Fluid\Fluid\Core\ViewHelper\TagBuilder; + +/** + * Class TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandAdditionalFieldProvider + */ +class ExecuteSchedulableCommandAdditionalFieldProvider implements AdditionalFieldProviderInterface +{ + /** + * Commands that should not be schedulable, like scheduler:run, + * which would start a recursion. + * + * @var array + */ + protected static $blacklistedCommands = [ + \TYPO3\CMS\Scheduler\Command\SchedulerCommand::class, // scheduler:run + \TYPO3\CMS\Extbase\Command\CoreCommand::class, // _core_command + \TYPO3\CMS\Extbase\Command\HelpCommand::class, // _extbase_help + ]; + + /** + * @var array|Command[] + */ + protected $schedulableCommands = []; + + /** + * @var \TYPO3\CMS\Extbase\Mvc\Cli\CommandManager + */ + protected $commandManager; + + /** + * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface + */ + protected $objectManager; + + /** + * @var \TYPO3\CMS\Extbase\Reflection\ReflectionService + */ + protected $reflectionService; + + /** + * @var ExecuteSchedulableCommandTask + */ + protected $task; + + public function __construct() + { + $commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class); + foreach ($commandRegistry as $commandIdentifier => $command) { + if (in_array(get_class($command), static::$blacklistedCommands, true)) { + continue; + } + $this->schedulableCommands[$commandIdentifier] = $command; + } + + ksort($this->schedulableCommands); + } + + /** + * Render additional information fields within the scheduler backend. + * + * @param array &$taskInfo Array information of task to return + * @param mixed $task \TYPO3\CMS\Scheduler\Task\AbstractTask or \TYPO3\CMS\Scheduler\Execution instance + * @param SchedulerModuleController $schedulerModule Reference to the calling object (BE module of the Scheduler) + * @return array Additional fields + * @see \TYPO3\CMS\Scheduler\AdditionalFieldProvider#getAdditionalFields($taskInfo, $task, $schedulerModule) + */ + public function getAdditionalFields(array &$taskInfo, $task, SchedulerModuleController $schedulerModule): array + { + $this->task = $task; + if ($this->task !== null) { + $this->task->setScheduler(); + } + + $fields = []; + $fields['action'] = $this->getActionField(); + + if ($this->task !== null && isset($this->schedulableCommands[$this->task->getCommandIdentifier()])) { + $command = $this->schedulableCommands[$this->task->getCommandIdentifier()]; + $fields['description'] = $this->getCommandDescriptionField($command->getDescription()); + $argumentFields = $this->getCommandArgumentFields($command->getDefinition()); + $fields = array_merge($fields, $argumentFields); + $this->task->save(); // todo: this seems to be superfluous + } + + return $fields; + } + + /** + * Validates additional selected fields + * + * @param array &$submittedData + * @param SchedulerModuleController $schedulerModule + * @return bool + */ + public function validateAdditionalFields(array &$submittedData, SchedulerModuleController $schedulerModule): bool + { + if (!isset($this->schedulableCommands[$submittedData['task_executeschedulablecommand']['command']])) { + return false; + } + + $command = $this->schedulableCommands[$submittedData['task_executeschedulablecommand']['command']]; + + /** @var FlashMessageService $flashMessageService */ + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + + $hasErrors = false; + foreach ($command->getDefinition()->getArguments() as $argument) { + foreach ((array)$submittedData['task_executeschedulablecommand']['arguments'] as $argumentName => $argumentValue) { + /** @var string $argumentName */ + /** @var string $argumentValue */ + if ($argument->getName() !== $argumentName) { + continue; + } + + if ($argument->isRequired() && trim($argumentValue) === '') { + // Argument is required and argument value is empty0 + $flashMessageService->getMessageQueueByIdentifier()->addMessage( + new FlashMessage( + sprintf( + $this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.mandatoryArgumentMissing'), + $argumentName + ), + $this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.updateError'), + FlashMessage::ERROR + ) + ); + $hasErrors = true; + } + } + } + return $hasErrors === false; + } + + /** + * Saves additional field values + * + * @param array $submittedData + * @param AbstractTask $task + * @return bool + */ + public function saveAdditionalFields(array $submittedData, AbstractTask $task): bool + { + $command = $this->schedulableCommands[$submittedData['task_executeschedulablecommand']['command']]; + + /** @var ExecuteSchedulableCommandTask $task */ + $task->setCommandIdentifier($submittedData['task_executeschedulablecommand']['command']); + + $arguments = []; + foreach ((array)$submittedData['task_executeschedulablecommand']['arguments'] as $argumentName => $argumentValue) { + try { + $argumentDefinition = $command->getDefinition()->getArgument($argumentName); + } catch (InvalidArgumentException $e) { + continue; + } + + if ($argumentDefinition->isArray()) { + $argumentValue = GeneralUtility::trimExplode(',', $argumentValue, true); + } + + $arguments[$argumentName] = $argumentValue; + } + + $task->setArguments($arguments); + return true; + } + + /** + * Get description of selected command + * + * @param string $description + * @return array + */ + protected function getCommandDescriptionField(string $description): array + { + return [ + 'code' => '', + 'label' => '<strong>' . $description . '</strong>' + ]; + } + + /** + * Gets a select field containing all possible schedulable commands + * + * @return array + */ + protected function getActionField(): array + { + $currentlySelectedCommand = $this->task !== null ? $this->task->getCommandIdentifier() : ''; + $options = []; + foreach ($this->schedulableCommands as $commandIdentifier => $command) { + $options[$commandIdentifier] = $commandIdentifier . ': ' . $command->getDescription(); + } + return [ + 'code' => $this->renderSelectField($options, $currentlySelectedCommand), + 'label' => $this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.schedulableCommandName') + ]; + } + + /** + * Gets a set of fields covering arguments which can or must be used. + * Also registers the default values of those fields with the Task, allowing + * them to be read upon execution. + * + * @param InputDefinition $inputDefinition + * @return array + */ + protected function getCommandArgumentFields(InputDefinition $inputDefinition): array + { + $fields = []; + $argumentValues = $this->task->getArguments(); + foreach ($inputDefinition->getArguments() as $argument) { + $name = $argument->getName(); + $defaultValue = $argument->getDefault(); + $this->task->addDefaultValue($name, $defaultValue); + $value = $argumentValues[$name] ?? $defaultValue; + + if (is_array($value) && $argument->isArray()) { + $value = implode(',', $value); + } + + $fields[$name] = [ + 'code' => $this->renderField($argument, (string)$value), + 'label' => $this->getArgumentLabel($argument) + ]; + } + + return $fields; + } + + /** + * Get a human-readable label for a command argument + * + * @param InputArgument $argument + * @return string + */ + protected function getArgumentLabel(InputArgument $argument): string + { + return 'Argument: ' . $argument->getName() . '. <em>' . htmlspecialchars($argument->getDescription()) . '</em>'; + } + + /** + * @param array $options + * @param string $selectedOptionValue + * @return string + */ + protected function renderSelectField(array $options, string $selectedOptionValue): string + { + $selectTag = new TagBuilder(); + $selectTag->setTagName('select'); + $selectTag->forceClosingTag(true); + $selectTag->addAttribute('class', 'form-control'); + $selectTag->addAttribute('name', 'tx_scheduler[task_executeschedulablecommand][command]'); + + $optionsHtml = ''; + foreach ($options as $value => $label) { + $optionTag = new TagBuilder(); + $optionTag->setTagName('option'); + $optionTag->forceClosingTag(true); + $optionTag->addAttribute('title', (string)$label); + $optionTag->addAttribute('value', (string)$value); + $optionTag->setContent($label); + + if ($value === $selectedOptionValue) { + $optionTag->addAttribute('selected', 'selected'); + } + + $optionsHtml .= $optionTag->render(); + } + + $selectTag->setContent($optionsHtml); + return $selectTag->render(); + } + + /** + * Renders a field for defining an argument's value + * + * @param InputArgument $argument + * @param mixed $currentValue + * @return string + */ + protected function renderField(InputArgument $argument, string $currentValue): string + { + $name = $argument->getName(); + $fieldName = 'tx_scheduler[task_executeschedulablecommand][arguments][' . $name . ']'; + + $inputTag = new TagBuilder(); + $inputTag->setTagName('input'); + $inputTag->addAttribute('type', 'text'); + $inputTag->addAttribute('name', $fieldName); + $inputTag->addAttribute('value', $currentValue); + $inputTag->addAttribute('class', 'form-control'); + + return $inputTag->render(); + } + + /** + * @return LanguageService + */ + public function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandTask.php b/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandTask.php new file mode 100644 index 0000000000000000000000000000000000000000..35edb58ccaaf9542ebbf7162aa2b0a90c8f71342 --- /dev/null +++ b/typo3/sysext/scheduler/Classes/Task/ExecuteSchedulableCommandTask.php @@ -0,0 +1,129 @@ +<?php +declare(strict_types=1); +namespace TYPO3\CMS\Scheduler\Task; + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; +use TYPO3\CMS\Core\Console\CommandRegistry; +use TYPO3\CMS\Core\Console\UnknownCommandException; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Lang\LanguageService; + +/** + * Class TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask + */ +class ExecuteSchedulableCommandTask extends AbstractTask +{ + /** + * @var string + */ + protected $commandIdentifier = ''; + + /** + * @var array + */ + protected $arguments = []; + + /** + * @var array + */ + protected $defaults = []; + + /** + * @param string $commandIdentifier + */ + public function setCommandIdentifier(string $commandIdentifier) + { + $this->commandIdentifier = $commandIdentifier; + } + + /** + * @return string + */ + public function getCommandIdentifier(): string + { + return $this->commandIdentifier; + } + + /** + * This is the main method that is called when a task is executed + * It MUST be implemented by all classes inheriting from this one + * Note that there is no error handling, errors and failures are expected + * to be handled and logged by the client implementations. + * Should return TRUE on successful execution, FALSE on error. + * + * @throws \Exception + * + * @return bool Returns TRUE on successful execution, FALSE on error + */ + public function execute(): bool + { + try { + $commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class); + $schedulableCommand = $commandRegistry->getCommandByIdentifier($this->commandIdentifier); + } catch (UnknownCommandException $e) { + throw new \RuntimeException( + sprintf( + $this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.unregisteredCommand'), + $this->commandIdentifier + ), + 1505055445, + $e + ); + } + + $input = new ArrayInput($this->getArguments(), $schedulableCommand->getDefinition()); + $output = new NullOutput(); + + return $schedulableCommand->run($input, $output) === 0; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * @param array $arguments + */ + public function setArguments(array $arguments) + { + $this->arguments = $arguments; + } + + /** + * @param string $argumentName + * @param mixed $argumentValue + */ + public function addDefaultValue(string $argumentName, $argumentValue) + { + if (is_bool($argumentValue)) { + $argumentValue = (int)$argumentValue; + } + $this->defaults[$argumentName] = $argumentValue; + } + + /** + * @return LanguageService + */ + public function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/scheduler/Resources/Private/Language/locallang.xlf b/typo3/sysext/scheduler/Resources/Private/Language/locallang.xlf index 9b359697cecfcadf8a75f45ec50b3a853a522f8d..9122e973616817c9feea2f70bc8aa9df29f9dc8e 100644 --- a/typo3/sysext/scheduler/Resources/Private/Language/locallang.xlf +++ b/typo3/sysext/scheduler/Resources/Private/Language/locallang.xlf @@ -162,6 +162,9 @@ <trans-unit id="label.noGroup"> <source>(no task group defined)</source> </trans-unit> + <trans-unit id="label.schedulableCommandName"> + <source><![CDATA[Schedulable Command. <em>Save and reopen to define command arguments</em>]]></source> + </trans-unit> <trans-unit id="msg.addError"> <source>The task could not be added.</source> </trans-unit> @@ -306,6 +309,12 @@ <trans-unit id="msg.noDatabaseTablesSelected"> <source>Please select at least one database table.</source> </trans-unit> + <trans-unit id="msg.unregisteredCommand"> + <source>Command with identifier "%s" has not been registered.</source> + </trans-unit> + <trans-unit id="msg.mandatoryArgumentMissing"> + <source>Argument "%s" is mandatory</source> + </trans-unit> <trans-unit id="none"> <source>None</source> </trans-unit> @@ -390,6 +399,12 @@ <trans-unit id="recyclerGarbageCollection.description"> <source>This task empties all "_recycler_" folders below fileadmin. This helps free some space in the file system.</source> </trans-unit> + <trans-unit id="executeSchedulableCommandTask.name"> + <source>Execute console commands</source> + </trans-unit> + <trans-unit id="executeSchedulableCommandTask.description"> + <source>Allows regular console commands to be configured and executed through the scheduler framework.</source> + </trans-unit> <trans-unit id="optimizeDatabaseTable.name"> <source>Optimize MySQL database tables</source> </trans-unit> diff --git a/typo3/sysext/scheduler/ext_localconf.php b/typo3/sysext/scheduler/ext_localconf.php index b5f8badbd0901ceb50b8ca6f045b757366b021df..a8ec1ec7c4305e765ef52e25403106765f62c2a1 100644 --- a/typo3/sysext/scheduler/ext_localconf.php +++ b/typo3/sysext/scheduler/ext_localconf.php @@ -53,6 +53,14 @@ $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Sched 'additionalFields' => \TYPO3\CMS\Scheduler\Task\RecyclerGarbageCollectionAdditionalFieldProvider::class ]; +// Add execute schedulable command task +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask::class] = [ + 'extension' => 'scheduler', + 'title' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:executeSchedulableCommandTask.name', + 'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:executeSchedulableCommandTask.name', + 'additionalFields' => \TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandAdditionalFieldProvider::class +]; + // Save any previous option array for table garbage collection task // to temporary variable so it can be pre-populated by other // extensions and LocalConfiguration/AdditionalConfiguration