diff --git a/CHANGELOG.md b/CHANGELOG.md index 699307ad..f4fadd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Different compute device configuration for Daemon (NVIDIA, AMD, CPU). #267 - Ability to add optional parameters when registering a daemon, for example *OVERRIDE_APP_HOST*. #269 +- API for registering OCC commands. #272 - Correct support of the Docker `HEALTHCHECK` instruction. #273 - Support of pulling "custom" images for the selected compute device. #274 diff --git a/appinfo/register_command.php b/appinfo/register_command.php new file mode 100644 index 00000000..82874b81 --- /dev/null +++ b/appinfo/register_command.php @@ -0,0 +1,32 @@ +getSystemValueBool('installed', false)) { + $exAppOccService = Server::get(ExAppOccService::class); + /** + * @var ExAppOccCommand $occCommand + * @var SymfonyApplication $application + */ + foreach ($exAppOccService->getOccCommands() as $occCommand) { + $application->add($exAppOccService->buildCommand( + $occCommand, + $serverContainer + )); + } + } +} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { +} diff --git a/appinfo/routes.php b/appinfo/routes.php index 786df079..dc1835af 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -80,6 +80,11 @@ ['name' => 'EventsListener#unregisterListener', 'url' => '/api/v1/events_listener', 'verb' => 'DELETE'], ['name' => 'EventsListener#getListener', 'url' => '/api/v1/events_listener', 'verb' => 'GET'], + // Commands + ['name' => 'OccCommand#registerCommand', 'url' => '/api/v1/occ_command', 'verb' => 'POST'], + ['name' => 'OccCommand#unregisterCommand', 'url' => '/api/v1/occ_command', 'verb' => 'DELETE'], + ['name' => 'OccCommand#getCommand', 'url' => '/api/v1/occ_command', 'verb' => 'GET'], + // Talk bots ['name' => 'TalkBot#registerExAppTalkBot', 'url' => '/api/v1/talk_bot', 'verb' => 'POST'], ['name' => 'TalkBot#unregisterExAppTalkBot', 'url' => '/api/v1/talk_bot', 'verb' => 'DELETE'], diff --git a/docs/tech_details/ApiScopes.rst b/docs/tech_details/ApiScopes.rst index 1e70ec2f..6602bb1a 100644 --- a/docs/tech_details/ApiScopes.rst +++ b/docs/tech_details/ApiScopes.rst @@ -29,6 +29,7 @@ Supported API Groups include: * ``60`` TALK_BOT * ``61`` AI_PROVIDERS * ``62`` EVENTS_LISTENER +* ``63`` OCC_COMMAND * ``110`` ACTIVITIES * ``120`` NOTES * ``200`` TEXT_PROCESSING diff --git a/docs/tech_details/api/index.rst b/docs/tech_details/api/index.rst index b0eadb6a..86d5a88d 100644 --- a/docs/tech_details/api/index.rst +++ b/docs/tech_details/api/index.rst @@ -19,6 +19,7 @@ AppAPI Nextcloud APIs settings notifications events_listener + occ_command talkbots speechtotext textprocessing diff --git a/docs/tech_details/api/occ_command.rst b/docs/tech_details/api/occ_command.rst new file mode 100644 index 00000000..0e5069f2 --- /dev/null +++ b/docs/tech_details/api/occ_command.rst @@ -0,0 +1,108 @@ +.. _occ_command: + +=========== +OCC Command +=========== + +This API allows you to register the occ (CLI) commands. +The principal is similar to the regular Nextcloud OCC command for PHP apps, that are working in context of the Nextcloud instance, +but for ExApps it is a trigger via Nextcloud OCC interface to perform some action on the External App side. + + +.. note:: + + Passing files directly as an input argument to the occ command is not supported. + +Register +^^^^^^^^ + +OCS endpoint: ``POST /apps/app_api/api/v1/occ_command`` + +Params +****** + +.. code-block:: json + + { + "name": "appid:unique:command:name", + "description": "Description of the command", + "hidden": "true/false", + "arguments": [ + { + "name": "argument_name", + "mode": "required/optional/array", + "description": "Description of the argument", + "default": "default_value" + } + ], + "options": [ + { + "name": "option_name", + "shortcut": "s", + "mode": "required/optional/none/array/negatable", + "description": "Description of the option", + "default": "default_value" + } + ], + "usages": [ + "occ appid:unique:command:name argument_name --option_name", + "occ appid:unique:command:name argument_name -s" + ], + "execute_handler": "handler_route" + } + +For more details on the command arguments and options modes, +see the original docs for the Symfony console input parameters, which are actually being built from the provided data: +`https://symfony.com/doc/current/console/input.html#using-command-arguments `_ + + +Example +******* + +Lets assume we have a command `ping` that takes an argument `test_arg` and has an option `test-option`: + +.. code-block:: json + + { + "name": "my_app_id:ping", + "description": "Test ping command", + "hidden": "false", + "arguments": [ + { + "name": "test_arg", + "mode": "required", + "description": "Test argument", + "default": 123 + } + ], + "options": [ + { + "name": "test-option", + "shortcut": "t", + "mode": "none", + "description": "Test option", + } + ], + "usages": [ + "occ my_app_id:ping 12345", + "occ my_app_id:ping 12345 --test-option", + "occ my_app_id:ping 12345 -t" + ], + "execute_handler": "handler_route" + } + +Unregister +^^^^^^^^^^ + +OCS endpoint: ``DELETE /apps/app_api/api/v1/occ_command`` + +Params +****** + +To unregister OCC Command, you just need to provide a command `name`: + +.. code-block:: json + + { + "name": "occ_command_name" + } diff --git a/lib/Controller/OccCommandController.php b/lib/Controller/OccCommandController.php new file mode 100644 index 00000000..d9efa34b --- /dev/null +++ b/lib/Controller/OccCommandController.php @@ -0,0 +1,72 @@ +request = $request; + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function registerCommand( + string $name, + string $description, + string $execute_handler, + bool $hidden = false, + array $arguments = [], + array $options = [], + array $usages = [], + ): DataResponse { + $command = $this->service->registerCommand( + $this->request->getHeader('EX-APP-ID'), $name, + $description, $hidden, $arguments, $options, $usages, $execute_handler + ); + if ($command === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + return new DataResponse(); + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function unregisterCommand(string $name): DataResponse { + $unregistered = $this->service->unregisterCommand($this->request->getHeader('EX-APP-ID'), $name); + if (!$unregistered) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + return new DataResponse(); + } + + #[AppAPIAuth] + #[PublicPage] + #[NoCSRFRequired] + public function getCommand(string $name): DataResponse { + $result = $this->service->getOccCommand($this->request->getHeader('EX-APP-ID'), $name); + if (!$result) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + return new DataResponse($result, Http::STATUS_OK); + } +} diff --git a/lib/Db/Console/ExAppOccCommand.php b/lib/Db/Console/ExAppOccCommand.php new file mode 100644 index 00000000..37b245a5 --- /dev/null +++ b/lib/Db/Console/ExAppOccCommand.php @@ -0,0 +1,97 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('description', 'string'); + $this->addType('hidden', 'bool'); + $this->addType('arguments', 'json'); + $this->addType('options', 'json'); + $this->addType('usages', 'json'); + $this->addType('executeHandler', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['description'])) { + $this->setDescription($params['description']); + } + if (isset($params['hidden'])) { + $this->setHidden($params['hidden']); + } + if (isset($params['arguments'])) { + $this->setArguments($params['arguments']); + } + if (isset($params['options'])) { + $this->setOptions($params['options']); + } + if (isset($params['usages'])) { + $this->setUsages($params['usages']); + } + if (isset($params['execute_handler'])) { + $this->setExecuteHandler($params['execute_handler']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'hidden' => $this->getHidden(), + 'arguments' => $this->getArguments(), + 'options' => $this->getOptions(), + 'usages' => $this->getUsages(), + 'execute_handler' => $this->getExecuteHandler(), + ]; + } +} diff --git a/lib/Db/Console/ExAppOccCommandMapper.php b/lib/Db/Console/ExAppOccCommandMapper.php new file mode 100644 index 00000000..b350a55d --- /dev/null +++ b/lib/Db/Console/ExAppOccCommandMapper.php @@ -0,0 +1,63 @@ + + */ +class ExAppOccCommandMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_occ_commands'); + } + + /** + * @throws Exception + */ + public function findAllEnabled(): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('exs.*') + ->from($this->tableName, 'exs') + ->innerJoin('exs', 'ex_apps', 'exa', 'exa.appid = exs.appid') + ->where( + $qb->expr()->eq('exa.enabled', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ) + ->executeQuery(); + return $result->fetchAll(); + } + + public function removeByAppIdOccName(string $appId, string $name): bool { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)) + ); + try { + $result = $qb->executeStatement(); + if ($result) { + return true; + } + } catch (Exception) { + } + return false; + } + + /** + * @throws Exception + */ + public function removeAllByAppId(string $appId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)) + ); + return $qb->executeStatement(); + } +} diff --git a/lib/DeployActions/DockerActions.php b/lib/DeployActions/DockerActions.php index 7a17ccde..d6e0e007 100644 --- a/lib/DeployActions/DockerActions.php +++ b/lib/DeployActions/DockerActions.php @@ -602,14 +602,6 @@ public function buildExAppVolumeName(string $appId): string { return self::EX_APP_CONTAINER_PREFIX . $appId . '_data'; } - private function isGPUAvailable(): bool { - $gpusDir = '/dev/dri'; - if (is_dir($gpusDir) && is_readable($gpusDir)) { - return true; - } - return false; - } - /** * Return default GPU device requests for container. */ diff --git a/lib/Migration/Version2205Date20240411124836.php b/lib/Migration/Version2205Date20240411124836.php new file mode 100644 index 00000000..492fbee8 --- /dev/null +++ b/lib/Migration/Version2205Date20240411124836.php @@ -0,0 +1,73 @@ +hasTable('ex_occ_commands')) { + $table = $schema->createTable('ex_occ_commands'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + // Symfony\Component\Console\Command\Command->setName() + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + // Symfony\Component\Console\Command\Command->setDescription() + $table->addColumn('description', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + // Symfony\Component\Console\Command\Command->setHidden() + $table->addColumn('hidden', Types::SMALLINT, [ + 'notnull' => true, + ]); + // Symfony\Component\Console\Command\Command->addArgument() + $table->addColumn('arguments', Types::JSON, [ + 'notnull' => true, + ]); + // Symfony\Component\Console\Command\Command->addOption() + $table->addColumn('options', Types::JSON, [ + 'notnull' => true, + ]); + // Symfony\Component\Console\Command\Command->addUsage() + $table->addColumn('usages', Types::JSON, [ + 'notnull' => true, + ]); + $table->addColumn('execute_handler', Types::STRING, [ + 'notnull' => true, + 'length' => 410, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'name'], 'ex_occ_commands__idx'); + } + + return $schema; + } +} diff --git a/lib/Service/ExAppApiScopeService.php b/lib/Service/ExAppApiScopeService.php index c0903ddf..905e3b3d 100644 --- a/lib/Service/ExAppApiScopeService.php +++ b/lib/Service/ExAppApiScopeService.php @@ -33,6 +33,7 @@ public function __construct( ['api_route' => $aeApiV1Prefix . '/talk_bot', 'scope_group' => 60, 'name' => 'TALK_BOT', 'user_check' => 0], ['api_route' => $aeApiV1Prefix . '/ai_provider/', 'scope_group' => 61, 'name' => 'AI_PROVIDERS', 'user_check' => 0], ['api_route' => $aeApiV1Prefix . '/events_listener', 'scope_group' => 62, 'name' => 'EVENTS_LISTENER', 'user_check' => 0], + ['api_route' => $aeApiV1Prefix . '/occ_command', 'scope_group' => 63, 'name' => 'OCC_COMMAND', 'user_check' => 0], // AppAPI internal scopes ['api_route' => '/apps/app_api/apps/status', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0], diff --git a/lib/Service/ExAppEventsListenerService.php b/lib/Service/ExAppEventsListenerService.php index a003d02a..07fa500f 100644 --- a/lib/Service/ExAppEventsListenerService.php +++ b/lib/Service/ExAppEventsListenerService.php @@ -92,6 +92,16 @@ public function getEventsListeners(): array { } } + public function unregisterExAppEventListeners(string $appId): int { + try { + $result = $this->mapper->removeAllByAppId($appId); + } catch (Exception) { + $result = -1; + } + $this->resetCacheEnabled(); + return $result; + } + public function resetCacheEnabled(): void { $this->cache->remove('/ex_events_listener'); } diff --git a/lib/Service/ExAppOccService.php b/lib/Service/ExAppOccService.php new file mode 100644 index 00000000..261d82da --- /dev/null +++ b/lib/Service/ExAppOccService.php @@ -0,0 +1,266 @@ +cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_occ_commands'); + } + + public function registerCommand( + string $appId, + string $name, + string $description, + bool $hidden, + array $arguments, + array $options, + array $usages, + string $executeHandler + ): ?ExAppOccCommand { + $occCommandEntry = $this->getOccCommand($appId, $name); + try { + $newOccCommandEntry = new ExAppOccCommand([ + 'appid' => $appId, + 'name' => $name, + 'description' => $description, + 'hidden' => $hidden, + 'arguments' => $arguments, + 'options' => $options, + 'usages' => $usages, + 'execute_handler' => ltrim($executeHandler, '/'), + ]); + if ($occCommandEntry !== null) { + $newOccCommandEntry->setId($occCommandEntry->getId()); + } + $occCommandEntry = $this->mapper->insertOrUpdate($newOccCommandEntry); + $this->resetCacheEnabled(); + } catch (Exception $e) { + $this->logger->error( + sprintf('Failed to register ExApp OCC command for %s. Error: %s', $appId, $e->getMessage()), ['exception' => $e] + ); + return null; + } + return $occCommandEntry; + } + + public function unregisterCommand(string $appId, string $name): bool { + if (!$this->mapper->removeByAppIdOccName($appId, $name)) { + return false; + } + $this->resetCacheEnabled(); + return true; + } + + public function getOccCommand(string $appId, string $name): ?ExAppOccCommand { + foreach ($this->getOccCommands() as $occCommand) { + if (($occCommand->getAppid() === $appId) && ($occCommand->getName() === $name)) { + return $occCommand; + } + } + return null; + } + + /** + * Get list of registered ExApp OCC Commands (only for enabled ExApps) + * + * @return ExAppOccCommand[] + */ + public function getOccCommands(): array { + try { + $cacheKey = '/ex_occ_commands'; + $records = $this->cache->get($cacheKey); + if ($records === null) { + $records = $this->mapper->findAllEnabled(); + $this->cache->set($cacheKey, $records); + } + return array_map(function ($record) { + return new ExAppOccCommand($record); + }, $records); + } catch (Exception) { + return []; + } + } + + public function buildCommand(ExAppOccCommand $occCommand, ContainerInterface $container): Command { + return new class($occCommand, $container) extends Command { + private LoggerInterface $logger; + private PublicFunctions $service; + + public function __construct( + private ExAppOccCommand $occCommand, + private ContainerInterface $container + ) { + parent::__construct(); + + $this->logger = $this->container->get(LoggerInterface::class); + $this->service = $this->container->get(PublicFunctions::class); + } + + protected function configure() { + $this->setName($this->occCommand->getName()); + $this->setDescription($this->occCommand->getDescription()); + $this->setHidden($this->occCommand->getHidden()); + foreach ($this->occCommand->getArguments() as $argument) { + $this->addArgument( + $argument['name'], + $this->buildArgumentMode($argument['mode']), + $argument['description'], + in_array($argument['mode'], ['optional', 'array']) ? $argument['default'] : null, + ); + } + foreach ($this->occCommand->getOptions() as $option) { + $this->addOption( + $option['name'], + $option['shortcut'] ?? null, + $this->buildOptionMode($option['mode']), + $option['description'], + $this->buildOptionMode($option['mode']) !== InputOption::VALUE_NONE + ? $option['default'] ?? null + : null + ); + } + foreach ($this->occCommand->getUsages() as $usage) { + $this->addUsage($usage); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if (count($this->occCommand->getArguments()) > 0) { + $arguments = []; + foreach ($this->occCommand->getArguments() as $argument) { + $arguments[$argument['name']] = $input->getArgument($argument['name']); + } + } else { + $arguments = null; + } + if (count($this->occCommand->getOptions()) > 0) { + $options = []; + foreach ($this->occCommand->getOptions() as $option) { + $options[$option['name']] = $input->getOption($option['name']); + } + } else { + $options = null; + } + + $executeHandler = $this->occCommand->getExecuteHandler(); + $response = $this->service->exAppRequest($this->occCommand->getAppid(), $executeHandler, + params: [ + 'occ' => [ + 'arguments' => $arguments, + 'options' => $options, + ], + ], + options: [ + 'stream' => true, + 'timeout' => 0, + ] + ); + + if (is_array($response) && isset($response['error'])) { + $output->writeln(sprintf('[%s] command executeHandler failed. Error: %s', $this->occCommand->getName(), $response['error'])); + $this->logger->error(sprintf('[%s] command executeHandler failed. Error: %s', $this->occCommand->getName(), $response['error']), [ + 'app' => $this->occCommand->getAppid(), + ]); + return 1; + } + + if ($response->getStatusCode() !== Http::STATUS_OK) { + $output->writeln(sprintf('[%s] command executeHandler failed', $this->occCommand->getName())); + $this->logger->error(sprintf('[%s] command executeHandler failed', $this->occCommand->getName()), [ + 'app' => $this->occCommand->getAppid(), + ]); + return 1; + } + + $body = $response->getBody(); + if (is_resource($body)) { + while (!feof($body)) { + $output->write(fread($body, 1024)); + } + } + + return 0; + } + + private function buildArgumentMode(string $mode): int { + $modes = explode(',', $mode); + $argumentMode = 0; + foreach ($modes as $mode) { + $argumentMode |= $this->_buildArgumentMode($mode); + } + return $argumentMode; + } + + private function _buildArgumentMode(string $mode): int { + if ($mode === 'required') { + return InputArgument::REQUIRED; + } + if ($mode === 'optional') { + return InputArgument::OPTIONAL; + } + if ($mode === 'array') { + return InputArgument::IS_ARRAY; + } + return InputArgument::OPTIONAL; + } + + private function buildOptionMode(string $mode): int { + if ($mode === 'required') { + return InputOption::VALUE_REQUIRED; + } + if ($mode === 'optional') { + return InputOption::VALUE_OPTIONAL; + } + if ($mode === 'none') { + return InputOption::VALUE_NONE; + } + if ($mode === 'array') { + return InputOption::VALUE_IS_ARRAY; + } + if ($mode === 'negatable') { + return InputOption::VALUE_NEGATABLE; + } + return InputOption::VALUE_NONE; + } + }; + } + + public function unregisterExAppOccCommands(string $appId): int { + try { + $result = $this->mapper->removeAllByAppId($appId); + } catch (Exception) { + $result = -1; + } + $this->resetCacheEnabled(); + return $result; + } + + public function resetCacheEnabled(): void { + $this->cache->remove('/ex_occ_commands'); + } +} diff --git a/lib/Service/ExAppService.php b/lib/Service/ExAppService.php index 3eb840f1..739a8895 100644 --- a/lib/Service/ExAppService.php +++ b/lib/Service/ExAppService.php @@ -52,7 +52,8 @@ public function __construct( private readonly TranslationService $translationService, private readonly TalkBotsService $talkBotsService, private readonly SettingsService $settingsService, - private readonly ExAppEventsListenerService $appEventsListenerService, + private readonly ExAppEventsListenerService $eventsListenerService, + private readonly ExAppOccService $occService, ) { $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/service'); } @@ -110,6 +111,8 @@ public function unregisterExApp(string $appId): bool { $this->translationService->unregisterExAppTranslationProviders($appId); $this->settingsService->unregisterExAppForms($appId); $this->exAppArchiveFetcher->removeExAppFolder($appId); + $this->eventsListenerService->unregisterExAppEventListeners($appId); + $this->occService->unregisterExAppOccCommands($appId); $r = $this->exAppMapper->deleteExApp($appId); if ($r !== 1) { $this->logger->error(sprintf('Error while unregistering %s ExApp from the database.', $appId)); @@ -239,7 +242,8 @@ private function resetCaches(): void { $this->speechToTextService->resetCacheEnabled(); $this->translationService->resetCacheEnabled(); $this->settingsService->resetCacheEnabled(); - $this->appEventsListenerService->resetCacheEnabled(); + $this->eventsListenerService->resetCacheEnabled(); + $this->occService->resetCacheEnabled(); } public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo): array {