diff --git a/.github/workflows/tests-deploy.yml b/.github/workflows/tests-deploy.yml index 121c937f..ba0cfb23 100644 --- a/.github/workflows/tests-deploy.yml +++ b/.github/workflows/tests-deploy.yml @@ -94,8 +94,6 @@ jobs: run: | PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8080 & ./occ app_api:daemon:register docker_local_sock Docker docker-install http /var/run/docker.sock http://127.0.0.1:8080/index.php - ./occ app_api:app:deploy skeleton docker_local_sock \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:register skeleton docker_local_sock \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:enable skeleton @@ -171,8 +169,6 @@ jobs: docker_local_sock Docker docker-install http /var/run/docker.sock http://nextcloud/index.php \ --net=master_bridge docker exec nextcloud sudo -u www-data php occ app_api:daemon:list - docker exec nextcloud sudo -u www-data php occ app_api:app:deploy skeleton docker_local_sock \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:register skeleton docker_local_sock \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:enable skeleton @@ -252,8 +248,6 @@ jobs: docker_by_port Docker docker-install http nextcloud-appapi-dsp:2375 http://nextcloud/index.php \ --net=master_bridge --haproxy_password=some_secure_password docker exec nextcloud sudo -u www-data php occ app_api:daemon:list - docker exec nextcloud sudo -u www-data php occ app_api:app:deploy skeleton docker_by_port \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:register skeleton docker_by_port \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:enable skeleton @@ -345,8 +339,6 @@ jobs: docker_by_port Docker docker-install https host.docker.internal:2375 http://localhost:8080/index.php \ --net=host --haproxy_password=some_secure_password docker exec nextcloud sudo -u www-data php occ app_api:daemon:list - docker exec nextcloud sudo -u www-data php occ app_api:app:deploy skeleton docker_by_port \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:register skeleton docker_by_port \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml docker exec nextcloud sudo -u www-data php occ app_api:app:enable skeleton @@ -496,8 +488,6 @@ jobs: PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8080 & ./occ app_api:daemon:register docker_local_sock Docker docker-install http /var/run/docker.sock http://127.0.0.1:8080/index.php ./occ app_api:daemon:list - ./occ app_api:app:deploy skeleton docker_local_sock \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:register skeleton docker_local_sock \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:enable skeleton @@ -622,8 +612,6 @@ jobs: docker_socket_local Docker docker-install http /var/run/docker.sock http://127.0.0.1:8080/index.php \ --net=host --set-default ./occ app_api:daemon:list - ./occ app_api:app:deploy skeleton \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:register skeleton \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/skeleton/appinfo/info.xml ./occ app_api:app:enable skeleton diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e163e72..bdf320ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] +## [2.1.0 - 2024-02-19] + +### Changed + +- `deploy` command was deprecated, now `register` and `deploy` is one step. #233 +- Installation of ExApps algorithm has been rewritten to provide a more comfortable experience. #233 + +### Fixed + +- Translation provider API correctly supports "language detection" feature. #232 ## [2.0.4 - 2024-02-08] diff --git a/appinfo/info.xml b/appinfo/info.xml index fae9c21f..ccbcb79e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -77,7 +77,6 @@ to join us in shaping a more versatile, stable, and secure app landscape. OCA\AppAPI\Command\ExApp\Deploy OCA\AppAPI\Command\ExApp\Register OCA\AppAPI\Command\ExApp\Unregister - OCA\AppAPI\Command\ExApp\DispatchInit OCA\AppAPI\Command\ExApp\Update OCA\AppAPI\Command\ExApp\Enable OCA\AppAPI\Command\ExApp\Disable diff --git a/appinfo/routes.php b/appinfo/routes.php index 03f205af..8aa508a0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -33,9 +33,7 @@ ['name' => 'ExAppsPage#enableApp', 'url' => '/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#enableApp', 'url' => '/apps/enable/{appId}', 'verb' => 'POST' , 'root' => ''], ['name' => 'ExAppsPage#getAppStatus', 'url' => '/apps/status/{appId}', 'verb' => 'GET' , 'root' => ''], - ['name' => 'ExAppsPage#enableApps', 'url' => '/apps/enable', 'verb' => 'POST' , 'root' => ''], ['name' => 'ExAppsPage#disableApp', 'url' => '/apps/disable/{appId}', 'verb' => 'GET' , 'root' => ''], - ['name' => 'ExAppsPage#disableApps', 'url' => '/apps/disable', 'verb' => 'POST' , 'root' => ''], ['name' => 'ExAppsPage#updateApp', 'url' => '/apps/update/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#uninstallApp', 'url' => '/apps/uninstall/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#viewApps', 'url' => '/apps/{category}', 'verb' => 'GET', 'defaults' => ['category' => ''] , 'root' => ''], @@ -55,10 +53,11 @@ ['name' => 'OCSApi#log', 'url' => '/api/v1/log', 'verb' => 'POST'], ['name' => 'OCSApi#getNCUsersList', 'url' => '/api/v1/users', 'verb' => 'GET'], - ['name' => 'OCSApi#setAppProgress', 'url' => '/apps/status/{appId}', 'verb' => 'PUT'], + ['name' => 'OCSApi#setAppInitProgress', 'url' => '/apps/status/{appId}', 'verb' => 'PUT'], // ExApps ['name' => 'OCSExApp#getExAppsList', 'url' => '/api/v1/ex-app/{list}', 'verb' => 'GET'], + ['name' => 'OCSExApp#getExApp', 'url' => '/api/v1/ex-app/info/{appId}', 'verb' => 'GET'], // ExApps actions ['name' => 'OCSExApp#setExAppEnabled', 'url' => '/api/v1/ex-app/{appId}/enabled', 'verb' => 'PUT'], diff --git a/docs/DevSetup.rst b/docs/DevSetup.rst index 2acec832..f4c12d1d 100644 --- a/docs/DevSetup.rst +++ b/docs/DevSetup.rst @@ -36,16 +36,15 @@ To see the complete list, execute ``make help``. Docker remote API ***************** -The Docker Engine remote API can be easily configured via ``make dock2port`` and ``make dock-certs`` commands. -The first one will create a docker container to provide remote Docker Engine API. -The second one will configure generated certificates for created container with Docker remote API in Nextcloud. +The Docker Engine remote API can be easily configured via ``make dock2port`` command. +The command will create a docker container to provide remote Docker Engine API. Afterward, register DaemonConfigs in Nextcloud using ``make dock-port`` command. Docker by socket **************** -For Docker via socket, use the command ``make dock2sock``. +For Docker via socket, use the command ``make dock-sock``. This registers DaemonConfigs in Nextcloud for the default socket connection (``/var/run/docker.sock``). Make sure that socket has enough permissions for Nextcloud and webserver user to access it diff --git a/docs/ManagingExternalApplications.rst b/docs/ManagingExternalApplications.rst index 0416cd50..6e144cee 100644 --- a/docs/ManagingExternalApplications.rst +++ b/docs/ManagingExternalApplications.rst @@ -12,40 +12,21 @@ OCC CLI There are several commands to work with ExApps: -1. Deploy -2. Register -3. Unregister -4. Update -5. Enable -6. Disable -7. List ExApps -8. List ExApp users -9. List ExApp scopes - -Deploy ------- - -Command: ``app_api:app:deploy [--info-xml INFO-XML] [--] `` - -The deploy command is the first ExApp installation step. - -Arguments -********* - - * ``appid`` - unique name of the ExApp (e.g. ``app_python_skeleton``, must be the same as in ``info.xml``) - * ``daemon-config-name`` - unique name of the daemon (e.g. ``docker_local_sock``) - -Options -******* - - * ``--info-xml INFO-XML`` **[required]** - path to info.xml file (url or local absolute path) +1. Register +2. Unregister +3. Update +4. Enable +5. Disable +6. List ExApps +7. List ExApp users +8. List ExApp scopes Register -------- Command: ``app_api:app:register [--force-scopes] [--info-xml INFO-XML] [--json-info JSON-INFO] [--] `` -The register command is the second ExApp installation step. +The register command is the first ExApp installation step. Arguments ********* @@ -58,7 +39,7 @@ Options * ``--force-scopes`` *[optional]* - force scopes approval * ``--json-info JSON-INFO`` **[optional]** - ExApp deploy JSON info (json string) - * ``--info-xml INFO-XML`` **[required]** - path to info.xml file (url or local absolute path) + * ``--info-xml INFO-XML`` **[optional]** - path to info.xml file (url or local absolute path) Unregister diff --git a/docs/tech_details/Deployment.rst b/docs/tech_details/Deployment.rst index 4521290a..3e7fa24a 100644 --- a/docs/tech_details/Deployment.rst +++ b/docs/tech_details/Deployment.rst @@ -4,11 +4,10 @@ Deployment Overview -------- -AppAPI ExApps deployment process in short consists of 3 steps: +AppAPI ExApps deployment process in short consists of 2 steps: 1. `DaemonConfig registration`_ -2. `ExApp deployment`_ -3. `ExApp registration`_ +2. `ExApp registration`_ .. _occ_daemon_config_registration: @@ -59,71 +58,48 @@ Example of ``occ`` **app_api:daemon:register** command: sudo -u www-data php occ app_api:daemon:register docker_local_sock "My Local Docker" docker-install http /var/run/docker.sock "https://nextcloud.local" --net nextcloud -ExApp deployment ----------------- +ExApp registration +------------------ -Second step is to deploy ExApp on registered daemon. -This can be done by ``occ`` CLI command **app_api:app:deploy**: +Second and final step is to deploy and register ExApp in Nextcloud on previously registered daemon. +This can be done by ``occ`` CLI command **app_api:app:register**: .. code-block:: bash - app_api:app:deploy [--info-xml INFO-XML] [--] - -.. note:: - For development this step is skipped, as ExApp is deployed and started manually by developer. - -.. warning:: - After successful deployment (pull, create and start container), there is a heartbeat check with 90 seconds timeout (will be configurable). + app_api:app:register [--force-scopes] [--] Arguments ********* - * ``appid`` - unique name of the ExApp (e.g. ``app_python_skeleton``, must be the same as in ``info.xml``) + * ``appid`` - unique name of the ExApp (e.g. ``app_python_skeleton``, must be the same as in deployed container) * ``daemon-config-name`` - unique name of the daemon (e.g. ``docker_local_sock``) Options ******* - * ``--info-xml INFO-XML`` **[required]** - path to info.xml file (url or local absolute path) - -Deploy result JSON -****************** - -Example of deploy result JSON: - -.. code-block:: - - { - "appid": "app_python_skeleton", - "name":"App Python Skeleton", - "daemon_config_name": "local_docker_sock", - "version":"1.0.0", - "secret":"***generated-secret***", - "host":"app_python_skeleton", - "port":"9001", - "system_app": true - } - -This JSON structure is used in ExApp registration step for development. + * ``--force-scopes`` **[optional]** - force scopes approval + * ``--info-xml INFO-XML`` **[optional]** - path to info.xml file with ExApp description (url or local absolute path) + * ``--json-info JSON-INFO`` **[optional]** - JSON with ExApp description +.. warning:: + After successful deployment (pull, create and start container), there is a heartbeat check with 90 seconds timeout (will be configurable). Manual install for development ****************************** For development purposes, you can install ExApp manually. There is a ``manual-install`` DeployConfig type, which can be used in case of development. -For ExApp registration with it you need to provide JSON app info with structure described before -using **app_api:app:register** ``--json-info`` option. +For ExApp registration with it you need to provide JSON app info or a path to app XML file. For all examples and applications we release we usually add manual_install command in it's makefile for easier development. .. code-block:: php occ app_api:app:register nc_py_api manual_install --json-info \ - "{\"appid\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":$APP_PORT,\"scopes\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\", \"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"],\"system_app\":1}" \ + "{\"id\":\"nc_py_api\",\"name\":\"nc_py_api\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":$APP_PORT,\"scopes\":[\"SYSTEM\", \"FILES\", \"FILES_SHARING\", \"USER_INFO\", \"USER_STATUS\", \"NOTIFICATIONS\", \"WEATHER_STATUS\", \"TALK\"],\"system\":1}" \ --force-scopes -.. note:: **Deployment/Startup of App should be done by developer when manual_install DeployConfig type is used.** +.. note:: **Deployment/Startup of App should be done by developer when ``manual-install`` DeployConfig type is used.** Deploy env variables ******************** @@ -139,37 +115,8 @@ The following env variables are required and built automatically: * ``APP_HOST`` - host ExApp is listening on * ``APP_PORT`` - port ExApp is listening on (randomly selected by AppAPI) * ``APP_PERSISTENT_STORAGE`` - path to mounted volume for persistent data storage between ExApp updates - * ``IS_SYSTEM_APP`` - ExApp system app flag (true|false) * ``NEXTCLOUD_URL`` - Nextcloud URL to connect to -.. note:: - Additional envs can be passed using multiple ``--env ENV_NAME=ENV_VAL`` options - -ExApp registration ------------------- - -Final step is to register ExApp in Nextcloud. -This can be done by ``occ`` CLI command **app_api:app:register**: - -.. code-block:: bash - - app_api:app:register [--force-scopes] [--] - -Arguments -********* - - * ``appid`` - unique name of the ExApp (e.g. ``app_python_skeleton``, must be the same as in deployed container) - * ``daemon-config-name`` - unique name of the daemon (e.g. ``docker_local_sock``) - -Options -******* - - * ``--force-scopes`` *[optional]* - force scopes approval - * ``--json-info JSON-INFO`` **[required]** - path to JSON file with deploy result (url or local absolute path) - -With provided ``appid`` and ``daemon-config-name``, Nextcloud will retrieve ExApp info from deployed container and register it. -In case of ``manual-install`` DeployConfig type, ExApp info must be provided by ``--json-info`` option `as described before <#deploy-result-json-output>`_. - Application installation scheme ------------------------------- diff --git a/docs/tech_details/api/machinetranslation.rst b/docs/tech_details/api/machinetranslation.rst index 47db9b6a..7c9fd27a 100644 --- a/docs/tech_details/api/machinetranslation.rst +++ b/docs/tech_details/api/machinetranslation.rst @@ -30,7 +30,7 @@ Request data "fr": "French", }, "action_handler": "/handler_route_on_ex_app", - "action_detect_lang": "/detect_lang_fromt_text_handler", + "action_detect_lang": "/detect_lang_from_text_handler", } .. note:: diff --git a/lib/BackgroundJob/ExAppInitStatusCheckJob.php b/lib/BackgroundJob/ExAppInitStatusCheckJob.php index f1ca850d..ac5a67aa 100644 --- a/lib/BackgroundJob/ExAppInitStatusCheckJob.php +++ b/lib/BackgroundJob/ExAppInitStatusCheckJob.php @@ -16,10 +16,10 @@ class ExAppInitStatusCheckJob extends TimedJob { private const everyMinuteInterval = 60; public function __construct( - ITimeFactory $time, - private ExAppMapper $mapper, - private AppAPIService $service, - private IConfig $config, + ITimeFactory $time, + private readonly ExAppMapper $mapper, + private readonly AppAPIService $service, + private readonly IConfig $config, ) { parent::__construct($time); @@ -34,13 +34,15 @@ protected function run($argument): void { $initTimeoutMinutes = intval($this->config->getAppValue(Application::APP_ID, 'init_timeout', '40')); foreach ($exApps as $exApp) { $status = $exApp->getStatus(); - if (!isset($status['init_start_time'])) { - continue; - } - if (time() >= ($status['init_start_time'] + $initTimeoutMinutes * 60)) { - $this->service->setAppInitProgress( - $exApp->getAppid(), 0, sprintf('ExApp %s initialization timed out (%sm)', $exApp->getAppid(), $initTimeoutMinutes * 60) - ); + if (isset($status['init']) && $status['init'] !== 100) { + if (!isset($status['init_start_time'])) { + continue; + } + if (time() >= ($status['init_start_time'] + $initTimeoutMinutes * 60)) { + $this->service->setAppInitProgress( + $exApp, 0, sprintf('ExApp %s initialization timed out (%sm)', $exApp->getAppid(), $initTimeoutMinutes * 60) + ); + } } } } catch (Exception) { diff --git a/lib/Command/ExApp/Deploy.php b/lib/Command/ExApp/Deploy.php index 68044d93..4ac8953a 100644 --- a/lib/Command/ExApp/Deploy.php +++ b/lib/Command/ExApp/Deploy.php @@ -4,13 +4,6 @@ namespace OCA\AppAPI\Command\ExApp; -use OCA\AppAPI\AppInfo\Application; -use OCA\AppAPI\DeployActions\DockerActions; -use OCA\AppAPI\Service\AppAPIService; -use OCA\AppAPI\Service\DaemonConfigService; - -use OCA\AppAPI\Service\ExAppService; -use OCP\IConfig; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -20,11 +13,6 @@ class Deploy extends Command { public function __construct( - private readonly AppAPIService $service, - private readonly ExAppService $exAppService, - private readonly DaemonConfigService $daemonConfigService, - private readonly DockerActions $dockerActions, - private readonly IConfig $config, ) { parent::__construct(); } @@ -36,79 +24,11 @@ protected function configure(): void { $this->addArgument('appid', InputArgument::REQUIRED); $this->addArgument('daemon-config-name', InputArgument::OPTIONAL); - $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, '[required] Path to ExApp info.xml file (url or local absolute path)'); + $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, 'Path to ExApp info.xml file (url or local absolute path)'); } protected function execute(InputInterface $input, OutputInterface $output): int { - $appId = $input->getArgument('appid'); - - $exApp = $this->exAppService->getExApp($appId); - if ($exApp !== null) { - $output->writeln(sprintf('ExApp %s already registered.', $appId)); - return 2; - } - - $pathToInfoXml = $input->getOption('info-xml'); - if ($pathToInfoXml !== null) { - $infoXml = simplexml_load_string(file_get_contents($pathToInfoXml)); - } else { - $infoXml = $this->exAppService->getLatestExAppInfoFromAppstore($appId); - // TODO: Add default release signature check and use of release archive download and info.xml file extraction - } - - if ($infoXml === false) { - $output->writeln(sprintf('Failed to load info.xml from %s', $pathToInfoXml)); - return 2; - } - if ($appId !== (string) $infoXml->id) { - $output->writeln(sprintf('ExApp appid %s does not match appid in info.xml (%s)', $appId, $infoXml->id)); - return 2; - } - - $daemonConfigName = $input->getArgument('daemon-config-name'); - if (!isset($daemonConfigName)) { - $daemonConfigName = $this->config->getAppValue(Application::APP_ID, 'default_daemon_config'); - } - $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($daemonConfigName); - if ($daemonConfig === null) { - $output->writeln(sprintf('Daemon config %s not found.', $daemonConfigName)); - return 2; - } - - $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $infoXml); - - [$pullResult, $createResult, $startResult] = $this->dockerActions->deployExApp($daemonConfig, $deployParams); - - if (isset($pullResult['error'])) { - $output->writeln(sprintf('ExApp %s deployment failed. Error: %s', $appId, $pullResult['error'])); - return 1; - } - - if (!isset($startResult['error']) && isset($createResult['Id'])) { - if (!$this->dockerActions->healthcheckContainer($this->dockerActions->buildExAppContainerName($appId), $daemonConfig)) { - $output->writeln(sprintf('ExApp %s deployment failed. Error: %s', $appId, 'Container healthcheck failed.')); - return 1; - } - - $auth = []; - $exAppUrl = $this->dockerActions->resolveExAppUrl( - $appId, - $daemonConfig->getProtocol(), - $daemonConfig->getHost(), - $daemonConfig->getDeployConfig(), - (int)explode('=', $deployParams['container_params']['env'][6])[1], - $auth, - ); - if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { - $output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure container started and initialized correctly.', $appId)); - return 2; - } - - $output->writeln(sprintf('ExApp %s deployed successfully', $appId)); - return 0; - } else { - $output->writeln(sprintf('ExApp %s deployment failed. Error: %s', $appId, $startResult['error'] ?? $createResult['error'])); - } - return 1; + $output->writeln("Use only `register` command, this command is deprecated."); + return 0; } } diff --git a/lib/Command/ExApp/DispatchInit.php b/lib/Command/ExApp/DispatchInit.php deleted file mode 100644 index dac8432b..00000000 --- a/lib/Command/ExApp/DispatchInit.php +++ /dev/null @@ -1,41 +0,0 @@ -setHidden(true); - $this->setName('app_api:app:dispatch_init'); - $this->setDescription('Internal command to dispatch init command'); - - $this->addArgument('appid', InputArgument::REQUIRED); - } - - protected function execute(InputInterface $input, OutputInterface $output): int { - $appId = $input->getArgument('appid'); - $exApp = $this->exAppService->getExApp($appId); - if ($exApp === null) { - $output->writeln(sprintf('ExApp %s not found. Failed to dispatch init.', $appId)); - return 1; - } - $this->service->dispatchExAppInitInternal($exApp); - return 0; - } -} diff --git a/lib/Command/ExApp/Register.php b/lib/Command/ExApp/Register.php index 38886dee..a47e6ea5 100644 --- a/lib/Command/ExApp/Register.php +++ b/lib/Command/ExApp/Register.php @@ -5,7 +5,6 @@ namespace OCA\AppAPI\Command\ExApp; use OCA\AppAPI\AppInfo\Application; -use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\DeployActions\DockerActions; use OCA\AppAPI\DeployActions\ManualActions; use OCA\AppAPI\Service\AppAPIService; @@ -17,6 +16,8 @@ use OCP\DB\Exception; use OCP\IConfig; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; @@ -30,138 +31,93 @@ class Register extends Command { public function __construct( private readonly AppAPIService $service, private readonly DaemonConfigService $daemonConfigService, - private readonly ExAppApiScopeService $exAppApiScopeService, private readonly ExAppScopesService $exAppScopesService, + private readonly ExAppApiScopeService $exAppApiScopeService, private readonly ExAppUsersService $exAppUsersService, private readonly DockerActions $dockerActions, private readonly ManualActions $manualActions, private readonly IConfig $config, private readonly ExAppService $exAppService, + private readonly ISecureRandom $random, + private readonly LoggerInterface $logger, ) { parent::__construct(); } protected function configure(): void { $this->setName('app_api:app:register'); - $this->setDescription('Register external app'); + $this->setDescription('Install external App'); $this->addArgument('appid', InputArgument::REQUIRED); $this->addArgument('daemon-config-name', InputArgument::OPTIONAL); $this->addOption('force-scopes', null, InputOption::VALUE_NONE, 'Force scopes approval'); - $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, '[required] Path to ExApp info.xml file (url or local absolute path)'); - $this->addOption('json-info', null, InputOption::VALUE_REQUIRED, 'ExApp JSON deploy info'); - $this->addOption('wait-finish', null, InputOption::VALUE_NONE, 'Wait until the end of the "init" phase.'); + $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, 'Path to ExApp info.xml file (url or local absolute path)'); + $this->addOption('json-info', null, InputOption::VALUE_REQUIRED, 'ExApp info.xml in JSON format'); + $this->addOption('wait-finish', null, InputOption::VALUE_NONE, 'Wait until finish'); + $this->addOption('silent', null, InputOption::VALUE_NONE, 'Do not print to console'); } protected function execute(InputInterface $input, OutputInterface $output): int { + $outputConsole = !$input->getOption('silent'); $appId = $input->getArgument('appid'); if ($this->exAppService->getExApp($appId) !== null) { - $output->writeln(sprintf('ExApp %s already registered.', $appId)); - return 2; + $this->logger->error(sprintf('ExApp %s is already registered.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s is already registered.', $appId)); + } + return 3; } + $appInfo = $this->exAppService->getAppInfo( + $appId, $input->getOption('info-xml'), $input->getOption('json-info') + ); + if (isset($appInfo['error'])) { + $this->logger->error($appInfo['error']); + if ($outputConsole) { + $output->writeln($appInfo['error']); + } + return 1; + } + $appId = $appInfo['id']; # value from $appInfo should have higher priority + $daemonConfigName = $input->getArgument('daemon-config-name'); if (!isset($daemonConfigName)) { $daemonConfigName = $this->config->getAppValue(Application::APP_ID, 'default_daemon_config'); } $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($daemonConfigName); if ($daemonConfig === null) { - $output->writeln(sprintf('Daemon config %s not found.', $daemonConfigName)); - return 2; - } - - if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { - $exAppInfo = $this->dockerActions->loadExAppInfo($appId, $daemonConfig); - if (array_key_exists('error', $exAppInfo)) { - $output->writeln($exAppInfo['error']); - $output->writeln('Did application was deployed before registration?'); - return 2; - } - } elseif ($daemonConfig->getAcceptsDeployId() === $this->manualActions->getAcceptsDeployId()) { - $exAppJson = $input->getOption('json-info'); - if ($exAppJson === null) { - $output->writeln('ExApp JSON is required for manual deploy.'); - return 2; - } - - $exAppInfo = $this->manualActions->loadExAppInfo($appId, $daemonConfig, [ - 'json-info' => $exAppJson, - ]); - - $auth = []; - $exAppUrl = $this->manualActions->resolveExAppUrl( - $appId, - $daemonConfig->getProtocol(), - $daemonConfig->getHost(), - $daemonConfig->getDeployConfig(), - (int) $exAppInfo['port'], - $auth, - ); - if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { - $output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure ExApp was started and initialized manually.', $appId)); - return 2; + $this->logger->error(sprintf('Daemon config %s not found.', $daemonConfigName)); + if ($outputConsole) { + $output->writeln(sprintf('Daemon config %s not found.', $daemonConfigName)); } - } else { - $output->writeln(sprintf('Daemon config %s actions for %s not found.', $daemonConfigName, $daemonConfig->getAcceptsDeployId())); return 2; } - $appId = $exAppInfo['appid']; - $version = $exAppInfo['version']; - $name = $exAppInfo['name']; - $port = (int) $exAppInfo['port']; - $secret = $exAppInfo['secret']; - - $exApp = $this->exAppService->registerExApp($appId, [ - 'version' => $version, - 'name' => $name, - 'daemon_config_name' => $daemonConfigName, - 'port' => $port, - 'secret' => $secret, - ]); - if ($exApp === null) { - $output->writeln(sprintf('Failed to register ExApp %s.', $appId)); - return 1; - } - - if (filter_var($exAppInfo['system_app'], FILTER_VALIDATE_BOOLEAN)) { - try { - $this->exAppUsersService->setupSystemAppFlag($exApp->getAppid()); - } catch (Exception $e) { - $output->writeln(sprintf('Error while setting app system flag: %s', $e->getMessage())); - return 1; + $actionsDeployIds = [ + $this->dockerActions->getAcceptsDeployId(), + $this->manualActions->getAcceptsDeployId(), + ]; + if (!in_array($daemonConfig->getAcceptsDeployId(), $actionsDeployIds)) { + $this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfigName, $daemonConfig->getAcceptsDeployId())); + if ($outputConsole) { + $output->writeln(sprintf('Daemon config %s actions for %s not found.', $daemonConfigName, $daemonConfig->getAcceptsDeployId())); } - } - - $pathToInfoXml = $input->getOption('info-xml'); - $infoXml = null; - if ($pathToInfoXml !== null) { - $infoXml = simplexml_load_string(file_get_contents($pathToInfoXml)); - if ($infoXml === false) { - $output->writeln(sprintf('Failed to load info.xml from %s', $pathToInfoXml)); - return 2; - } - } - - $requestedExAppScopeGroups = $this->exAppService->getExAppScopes($exApp, $infoXml, $exAppInfo); - if (isset($requestedExAppScopeGroups['error'])) { - $output->writeln($requestedExAppScopeGroups['error']); - $this->exAppService->unregisterExApp($exApp->getAppid()); return 2; } $forceScopes = (bool) $input->getOption('force-scopes'); $confirmRequiredScopes = $forceScopes; - if (!$forceScopes && $input->isInteractive()) { /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); // Prompt to approve required ExApp scopes - if (count($requestedExAppScopeGroups) > 0) { - $output->writeln(sprintf('ExApp %s requested required scopes: %s', $appId, implode(', ', $requestedExAppScopeGroups))); + if (count($appInfo['external-app']['scopes']) > 0) { + $output->writeln( + sprintf('ExApp %s requested required scopes: %s', $appId, implode(', ', $appInfo['external-app']['scopes'])) + ); $question = new ConfirmationQuestion('Do you want to approve it? [y/N] ', false); $confirmRequiredScopes = $helper->ask($input, $output, $question); } else { @@ -169,50 +125,120 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - if (!$confirmRequiredScopes && count($requestedExAppScopeGroups) > 0) { + if (!$confirmRequiredScopes && count($appInfo['external-app']['scopes']) > 0) { $output->writeln(sprintf('ExApp %s required scopes not approved.', $appId)); - $this->exAppService->unregisterExApp($exApp->getAppid()); return 1; } - if (count($requestedExAppScopeGroups) > 0) { - $this->registerExAppScopes($output, $exApp, $requestedExAppScopeGroups); + $appInfo['port'] = $appInfo['port'] ?? $this->exAppService->getExAppFreePort(); + $appInfo['secret'] = $appInfo['secret'] ?? $this->random->generate(128); + $appInfo['daemon_config_name'] = $appInfo['daemon_config_name'] ?? $daemonConfigName; + $exApp = $this->exAppService->registerExApp($appInfo); + if (!$exApp) { + $this->logger->error(sprintf('Error during registering ExApp %s.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('Error during registering ExApp %s.', $appId)); + } + return 3; } - - if (!$this->service->dispatchExAppInit($exApp->getAppid())) { - $output->writeln(sprintf('Dispatching init for ExApp %s fails.', $appId)); - $this->exAppService->unregisterExApp($exApp->getAppid()); - return 1; + if (filter_var($appInfo['external-app']['system'], FILTER_VALIDATE_BOOLEAN)) { + # TO-DO: refactor in next version: move "system" to the "ex_apps" table as a separate field, remove this. + try { + $this->exAppUsersService->setupSystemAppFlag($appId); + } catch (Exception $e) { + $this->exAppService->unregisterExApp($appId); + $output->writeln(sprintf('Error while setting app system flag: %s', $e->getMessage())); + return 1; + } } - $waitFinish = (bool) $input->getOption('wait-finish'); - if ($waitFinish) { - do { - $exApp = $this->exAppService->getExApp($appId); - $status = $exApp->getStatus(); - if (isset($status['error'])) { - $output->writeln(sprintf('ExApp %s initialization step failed. Error: %s', $appId, $status['error'])); - return 1; + if (count($appInfo['external-app']['scopes']) > 0) { + if (!$this->exAppScopesService->registerExAppScopes( + $exApp, $this->exAppApiScopeService->mapScopeNamesToNumbers($appInfo['external-app']['scopes'])) + ) { + $this->logger->error(sprintf('Error while registering API scopes for %s.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('Error while registering API scopes for %s.', $appId)); } - usleep(100000); // 0.1s - } while (isset($status['progress'])); + $this->exAppService->unregisterExApp($appId); + return 1; + } + $this->logger->info( + sprintf('ExApp %s scope groups successfully set: %s', $exApp->getAppid(), implode(', ', $appInfo['external-app']['scopes'])) + ); + if ($outputConsole) { + $output->writeln( + sprintf('ExApp %s scope groups successfully set: %s', $exApp->getAppid(), implode(', ', $appInfo['external-app']['scopes'])) + ); + } } - $output->writeln(sprintf('ExApp %s successfully registered.', $appId)); - return 0; - } + $auth = []; + if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { + $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $appInfo); + $deployResult = $this->dockerActions->deployExApp($exApp, $daemonConfig, $deployParams); + if ($deployResult) { + $this->logger->error(sprintf('ExApp %s deployment failed. Error: %s', $appId, $deployResult)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s deployment failed. Error: %s', $appId, $deployResult)); + } + $this->exAppService->unregisterExApp($appId); + return 1; + } - private function registerExAppScopes($output, ExApp $exApp, array $requestedExAppScopeGroups): void { - $registeredScopeGroups = []; - foreach ($this->exAppApiScopeService->mapScopeNamesToNumbers($requestedExAppScopeGroups) as $scopeGroup) { - if ($this->exAppScopesService->setExAppScopeGroup($exApp, $scopeGroup)) { - $registeredScopeGroups[] = $scopeGroup; - } else { - $output->writeln(sprintf('Failed to set %s ExApp scope group: %s', $exApp->getAppid(), $scopeGroup)); + if (!$this->dockerActions->healthcheckContainer($this->dockerActions->buildExAppContainerName($appId), $daemonConfig)) { + $this->logger->error(sprintf('ExApp %s deployment failed. Error: %s', $appId, 'Container healthcheck failed.')); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s deployment failed. Error: %s', $appId, 'Container healthcheck failed.')); + } + $this->exAppService->setStatusError($exApp, 'Container healthcheck failed'); + return 1; } + + $exAppUrl = $this->dockerActions->resolveExAppUrl( + $appId, + $daemonConfig->getProtocol(), + $daemonConfig->getHost(), + $daemonConfig->getDeployConfig(), + (int)explode('=', $deployParams['container_params']['env'][6])[1], + $auth, + ); + } else { + $this->manualActions->deployExApp($exApp, $daemonConfig); + $exAppUrl = $this->manualActions->resolveExAppUrl( + $appId, + $daemonConfig->getProtocol(), + $daemonConfig->getHost(), + $daemonConfig->getDeployConfig(), + (int) $appInfo['port'], + $auth, + ); } - if (count($registeredScopeGroups) > 0) { - $output->writeln(sprintf('ExApp %s scope groups successfully set: %s', $exApp->getAppid(), implode(', ', - $this->exAppApiScopeService->mapScopeGroupsToNames($registeredScopeGroups)))); + + if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { + $this->logger->error(sprintf('ExApp %s heartbeat check failed. Make sure that Nextcloud instance and ExApp can reach it other.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure that Nextcloud instance and ExApp can reach it other.', $appId)); + } + $this->exAppService->setStatusError($exApp, 'Heartbeat check failed'); + return 1; + } + $this->logger->info(sprintf('ExApp %s deployed successfully.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s deployed successfully.', $appId)); + } + + $this->service->dispatchExAppInitInternal($exApp); + if ($input->getOption('wait-finish')) { + $error = $this->exAppService->waitInitStepFinish($appId); + if ($error) { + $output->writeln($error); + return 1; + } } + $this->logger->info(sprintf('ExApp %s successfully registered.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s successfully registered.', $appId)); + } + return 0; } } diff --git a/lib/Command/ExApp/Unregister.php b/lib/Command/ExApp/Unregister.php index 5171c3a4..d4841f33 100644 --- a/lib/Command/ExApp/Unregister.php +++ b/lib/Command/ExApp/Unregister.php @@ -91,10 +91,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { $this->dockerActions->initGuzzleClient($daemonConfig); - [$stopResult, $removeResult] = $this->dockerActions->removePrevExAppContainer( + $removeResult = $this->dockerActions->removeContainer( $this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($appId) ); - if (isset($stopResult['error']) || isset($removeResult['error'])) { + if ($removeResult) { if (!$silent) { $output->writeln(sprintf('Failed to remove ExApp %s container', $appId)); } diff --git a/lib/Command/ExApp/Update.php b/lib/Command/ExApp/Update.php index bc9b9f93..f864983e 100644 --- a/lib/Command/ExApp/Update.php +++ b/lib/Command/ExApp/Update.php @@ -12,6 +12,7 @@ use OCA\AppAPI\Service\ExAppScopesService; use OCA\AppAPI\Service\ExAppService; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; @@ -29,6 +30,7 @@ public function __construct( private readonly ExAppApiScopeService $exAppApiScopeService, private readonly DaemonConfigService $daemonConfigService, private readonly DockerActions $dockerActions, + private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -39,124 +41,155 @@ protected function configure(): void { $this->addArgument('appid', InputArgument::REQUIRED); - $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, '[required] Path to ExApp info.xml file (url or local absolute path)'); - $this->addOption('force-update', null, InputOption::VALUE_NONE, 'Force ExApp update approval'); + $this->addOption('info-xml', null, InputOption::VALUE_REQUIRED, 'Path to ExApp info.xml file (url or local absolute path)'); + $this->addOption('json-info', null, InputOption::VALUE_REQUIRED, 'ExApp info.xml in JSON format'); $this->addOption('force-scopes', null, InputOption::VALUE_NONE, 'Force new ExApp scopes approval'); + $this->addOption('wait-finish', null, InputOption::VALUE_NONE, 'Wait until finish'); + $this->addOption('silent', null, InputOption::VALUE_NONE, 'Do not print to console'); } protected function execute(InputInterface $input, OutputInterface $output): int { + $outputConsole = !$input->getOption('silent'); $appId = $input->getArgument('appid'); - $pathToInfoXml = $input->getOption('info-xml'); - if ($pathToInfoXml !== null) { - $infoXml = simplexml_load_string(file_get_contents($pathToInfoXml)); - } else { - $infoXml = $this->exAppService->getLatestExAppInfoFromAppstore($appId); - } - - if ($infoXml === false) { - $output->writeln(sprintf('Failed to load info.xml from %s', $pathToInfoXml)); - return 2; - } - if ($appId !== (string) $infoXml->id) { - $output->writeln(sprintf('ExApp appid %s does not match appid in info.xml (%s)', $appId, $infoXml->id)); - return 2; + $appInfo = $this->exAppService->getAppInfo( + $appId, $input->getOption('info-xml'), $input->getOption('json-info') + ); + if (isset($appInfo['error'])) { + $this->logger->error($appInfo['error']); + if ($outputConsole) { + $output->writeln($appInfo['error']); + } + return 1; } + $appId = $appInfo['id']; # value from $appInfo should have higher priority $exApp = $this->exAppService->getExApp($appId); if ($exApp === null) { - $output->writeln(sprintf('ExApp %s not found.', $appId)); + $this->logger->error(sprintf('ExApp %s not found.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s not found.', $appId)); + } return 1; } - $newVersion = (string) $infoXml->version; - if ($exApp->getVersion() === $newVersion) { - $output->writeln(sprintf('ExApp %s already updated (%s)', $appId, $newVersion)); + $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); + if ($daemonConfig === null) { + $this->logger->error(sprintf('Daemon config %s not found.', $exApp->getDaemonConfigName())); + if ($outputConsole) { + $output->writeln(sprintf('Daemon config %s not found.', $exApp->getDaemonConfigName())); + } return 2; } - - if ($exApp->getEnabled()) { - if (!$this->service->disableExApp($exApp)) { - $output->writeln(sprintf('Failed to disable ExApp %s.', $appId)); - return 1; - } else { - $output->writeln(sprintf('ExApp %s disabled.', $appId)); + if ($daemonConfig->getAcceptsDeployId() === 'manual-install') { + $this->logger->error('For "manual-install" deployId update is done manually'); + if ($outputConsole) { + $output->writeln('For "manual-install" deployId update is done manually'); } + return 1; } - $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); - if ($daemonConfig === null) { - $output->writeln(sprintf('Daemon config %s not found', $exApp->getDaemonConfigName())); + if ($exApp->getVersion() === $appInfo['version']) { + $this->logger->warning(sprintf('ExApp %s is already updated (%s)', $appId, $appInfo['version'])); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s is already updated (%s)', $appId, $appInfo['version'])); + } + return 0; } - if ($daemonConfig->getAcceptsDeployId() === 'manual-install') { - $output->writeln('For "manual-install" deployId update is done manually'); - return 2; - } + $status = $exApp->getStatus(); + $status['type'] = 'update'; + $exApp->setStatus($status); + $this->exAppService->updateExApp($exApp); - if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { - $forceApproval = $input->getOption('force-update'); - $approveUpdate = $forceApproval; - if (!$forceApproval && $input->isInteractive()) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Current ExApp version will be removed (persistent storage preserved). Continue? [y/N] ', false); - $approveUpdate = $helper->ask($input, $output, $question); + if ($exApp->getEnabled()) { + if ($this->service->disableExApp($exApp)) { + $this->logger->info(sprintf('ExApp %s disabled.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s disabled.', $appId)); + } } - - if (!$approveUpdate) { - $output->writeln(sprintf('ExApp %s update canceled', $appId)); - return 0; + } else { + $this->logger->info(sprintf('ExApp %s was already disabled.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s was already disabled.', $appId)); } + } + $appInfo['port'] = $exApp->getPort(); + $appInfo['secret'] = $exApp->getSecret(); + $auth = []; + if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { $this->dockerActions->initGuzzleClient($daemonConfig); // Required init $containerInfo = $this->dockerActions->inspectContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($appId)); if (isset($containerInfo['error'])) { - $output->writeln(sprintf('Failed to inspect old ExApp %s container. Error: %s', $appId, $containerInfo['error'])); + $this->logger->error(sprintf('Failed to inspect old ExApp %s container. Error: %s', $appId, $containerInfo['error'])); + if ($outputConsole) { + $output->writeln(sprintf('Failed to inspect old ExApp %s container. Error: %s', $appId, $containerInfo['error'])); + } + $this->exAppService->setStatusError($exApp, 'Failed to inspect old container'); return 1; } - $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $infoXml, [ + $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $appInfo, [ 'container_info' => $containerInfo, ]); - [$pullResult, $stopResult, $removeResult, $createResult, $startResult] = $this->dockerActions->updateExApp($daemonConfig, $deployParams); - - if (isset($pullResult['error'])) { - $output->writeln(sprintf('ExApp %s update failed. Error: %s', $appId, $pullResult['error'])); - return 1; - } - - if (isset($stopResult['error']) || isset($removeResult['error'])) { - $output->writeln(sprintf('Failed to remove old ExApp %s container (id: %s). Error: %s', $appId, $containerInfo['Id'], $stopResult['error'] ?? $removeResult['error'] ?? null)); + $deployResult = $this->dockerActions->deployExApp($exApp, $daemonConfig, $deployParams); + if ($deployResult) { + $this->logger->error(sprintf('ExApp %s deployment update failed. Error: %s', $appId, $deployResult)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s deployment update failed. Error: %s', $appId, $deployResult)); + } + $this->exAppService->setStatusError($exApp, 'Deployment update failed'); return 1; } - if (!isset($startResult['error']) && isset($createResult['Id'])) { - if (!$this->dockerActions->healthcheckContainer($createResult['Id'], $daemonConfig)) { + if (!$this->dockerActions->healthcheckContainer($this->dockerActions->buildExAppContainerName($appId), $daemonConfig)) { + $this->logger->error(sprintf('ExApp %s update failed. Error: %s', $appId, 'Container healthcheck failed.')); + if ($outputConsole) { $output->writeln(sprintf('ExApp %s update failed. Error: %s', $appId, 'Container healthcheck failed.')); - return 1; } + $this->exAppService->setStatusError($exApp, 'Container healthcheck failed'); + return 1; + } - $auth = []; - $exAppUrl = $this->dockerActions->resolveExAppUrl( - $appId, - $daemonConfig->getProtocol(), - $daemonConfig->getHost(), - $daemonConfig->getDeployConfig(), - (int) $deployParams['container_params']['port'], - $auth, - ); - if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { - $output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure container started and configured correctly to be reachable by Nextcloud.', $appId)); - return 1; - } + $exAppUrl = $this->dockerActions->resolveExAppUrl( + $appId, + $daemonConfig->getProtocol(), + $daemonConfig->getHost(), + $daemonConfig->getDeployConfig(), + (int) $deployParams['container_params']['port'], + $auth, + ); + } else { + $this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId())); + if ($outputConsole) { + $output->writeln(sprintf('Daemon config %s actions for %s not found.', $daemonConfig->getName(), $daemonConfig->getAcceptsDeployId())); + } + $this->exAppService->setStatusError($exApp, 'Daemon actions not found'); + return 2; + } - $output->writeln(sprintf('ExApp %s container successfully updated.', $appId)); + if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { + $this->logger->error(sprintf('ExApp %s heartbeat check failed. Make sure that Nextcloud instance and ExApp can reach it other.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure that Nextcloud instance and ExApp can reach it other.', $appId)); } + $this->exAppService->setStatusError($exApp, 'Heartbeat check failed'); + return 1; + } + + $this->logger->info(sprintf('ExApp %s update successfully deployed.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s update successfully deployed.', $appId)); } $exAppInfo = $this->dockerActions->loadExAppInfo($appId, $daemonConfig); if (!$this->exAppService->updateExAppInfo($exApp, $exAppInfo)) { - $output->writeln(sprintf('Failed to update ExApp %s info', $appId)); + $this->logger->error(sprintf('Failed to update ExApp %s info', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('Failed to update ExApp %s info', $appId)); + } + $this->exAppService->setStatusError($exApp, 'Failed to update info'); return 1; } @@ -164,12 +197,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $currentExAppScopes = array_map(function (ExAppScope $exAppScope) { return $exAppScope->getScopeGroup(); }, $this->exAppScopeService->getExAppScopes($exApp)); - $newExAppScopes = $this->exAppService->getExAppScopes($exApp, $infoXml); - if (isset($newExAppScopes['error'])) { - $output->writeln($newExAppScopes['error']); - } // Prepare for prompt of newly requested ExApp scopes - $requiredScopes = $this->compareExAppScopes($currentExAppScopes, $newExAppScopes); + $requiredScopes = $this->compareExAppScopes($currentExAppScopes, $appInfo['external-app']['scopes']); $forceScopes = (bool) $input->getOption('force-scopes'); $confirmScopes = $forceScopes; @@ -193,18 +222,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - $newExAppScopes = $this->exAppApiScopeService->mapScopeNamesToNumbers($newExAppScopes); - if (!$this->exAppScopeService->updateExAppScopes($exApp, $newExAppScopes)) { - $output->writeln(sprintf('Failed to update ExApp %s scopes.', $appId)); + if (!$this->exAppScopeService->registerExAppScopes( + $exApp, $this->exAppApiScopeService->mapScopeNamesToNumbers($appInfo['external-app']['scopes'])) + ) { + $this->logger->error(sprintf('Failed to update ExApp %s scopes.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('Failed to update ExApp %s scopes.', $appId)); + } + $this->exAppService->setStatusError($exApp, 'Failed to update scopes'); return 1; } - if (!$this->service->dispatchExAppInit($exApp->getAppid(), true)) { - $output->writeln(sprintf('Dispatching init for ExApp %s fails.', $appId)); - return 1; + $this->service->dispatchExAppInitInternal($exApp); + if ($input->getOption('wait-finish')) { + $error = $this->exAppService->waitInitStepFinish($appId); + if ($error) { + $output->writeln($error); + return 1; + } + } + $this->logger->info(sprintf('ExApp %s successfully updated.', $appId)); + if ($outputConsole) { + $output->writeln(sprintf('ExApp %s successfully updated.', $appId)); } - - $output->writeln(sprintf('ExApp %s successfully updated.', $appId)); return 0; } diff --git a/lib/Controller/ExAppsPageController.php b/lib/Controller/ExAppsPageController.php index a2a7ff5e..70ee2f78 100644 --- a/lib/Controller/ExAppsPageController.php +++ b/lib/Controller/ExAppsPageController.php @@ -11,8 +11,6 @@ use OC\App\Platform; use OC_App; use OCA\AppAPI\AppInfo\Application; -use OCA\AppAPI\Db\DaemonConfig; -use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Db\ExAppScope; use OCA\AppAPI\DeployActions\DockerActions; use OCA\AppAPI\Fetcher\ExAppFetcher; @@ -36,7 +34,6 @@ use OCP\IRequest; use OCP\L10N\IFactory; use Psr\Log\LoggerInterface; -use SimpleXMLElement; /** * ExApps actions controller similar to default one with project-specific changes and additions @@ -108,6 +105,8 @@ public function viewApps(): TemplateResponse { $this->dockerActions->initGuzzleClient($daemonConfig); $daemonConfigAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig)); $appInitialData['daemon_config_accessible'] = $daemonConfigAccessible; + $appInitialData['default_daemon_config'] = $daemonConfig->jsonSerialize(); + unset($appInitialData['default_daemon_config']['deploy_config']['haproxy_password']); // do not expose password if (!$daemonConfigAccessible) { $this->logger->error(sprintf('Deploy daemon "%s" is not accessible by Nextcloud. Please verify its configuration', $daemonConfig->getName())); } @@ -193,11 +192,6 @@ private function getAppsForCategory(string $requestedCategory = ''): array { } $currentLanguage = substr(\OC::$server->getL10NFactory()->findLanguage(), 0, 2); - $enabledValue = $this->config->getAppValue($app['id'], 'enabled', 'no'); - $groups = null; - if ($enabledValue !== 'no' && $enabledValue !== 'yes') { - $groups = $enabledValue; - } if ($exApp !== null) { $currentVersion = $exApp->getVersion(); @@ -207,15 +201,12 @@ private function getAppsForCategory(string $requestedCategory = ''): array { $scopes = null; $daemon = null; - $exAppUrl = ''; if ($exApp !== null) { $scopes = $this->exAppApiScopeService->mapScopeGroupsToNames(array_map(function (ExAppScope $exAppScope) { return $exAppScope->getScopeGroup(); }, $this->exAppScopeService->getExAppScopes($exApp))); $daemon = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); - $auth = []; - $exAppUrl = $this->service->getExAppUrl($exApp, $exApp->getPort(), $auth); } $formattedApps[] = [ @@ -253,14 +244,13 @@ private function getAppsForCategory(string $requestedCategory = ''): array { 'removable' => $existsLocally, 'active' => $exApp !== null && $exApp->getEnabled() === 1, 'needsDownload' => !$existsLocally, - 'groups' => $groups, 'fromAppStore' => true, 'appstoreData' => $app, 'scopes' => $scopes, 'daemon' => $daemon, 'systemApp' => $exApp !== null && $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''), - 'exAppUrl' => $exAppUrl, 'status' => $exApp !== null ? $exApp->getStatus() : [], + 'error' => $exApp !== null ? $exApp->getStatus()['error'] ?? '' : '', ]; } @@ -299,13 +289,7 @@ public function listApps(): JSONResponse { }))[0]['releases'][0]['version']; } - // fix groups to be an array - $groups = []; - if (is_string($appData['groups'])) { - $groups = json_decode($appData['groups']); - } - $appData['groups'] = $groups; - $appData['canUnInstall'] = !$appData['active'] && $appData['removable']; + $appData['canUnInstall'] = !$appData['active'] && $appData['removable'] && !isset($appData['status']['type']); // fix licence vs license if (isset($appData['license']) && !isset($appData['licence'])) { @@ -391,7 +375,6 @@ private function buildLocalAppsList(array $apps, array $exApps): array { 'removable' => true, // to allow "remove" command for manual-install 'active' => $exApp->getEnabled() === 1, 'needsDownload' => false, - 'groups' => [], 'fromAppStore' => false, 'appstoreData' => $app, 'scopes' => $scopes, @@ -401,200 +384,59 @@ private function buildLocalAppsList(array $apps, array $exApps): array { 'releases' => [], 'update' => null, 'status' => $exApp->getStatus(), + 'error' => $exApp->getStatus()['error'] ?? '', ]; } } - $apps = array_merge($apps, $formattedLocalApps); - return $apps; + return array_merge($apps, $formattedLocalApps); } - /** - * @PasswordConfirmationRequired - * - * @param string $appId - * @param array $groups // TODO: Add support of groups later if needed - * - * @return JSONResponse - */ - public function enableApp(string $appId, array $groups = []): JSONResponse { - return $this->enableApps([$appId]); - } - - /** - * Enable one or more apps. - * Deploy ExApp if it was not deployed yet. - * - * @PasswordConfirmationRequired - */ - public function enableApps(array $appIds, array $groups = []): JSONResponse { - try { - $updateRequired = false; - - foreach ($appIds as $appId) { - // If ExApp is not null, assuming it was already deployed, therefore it could be registered - $exApp = $this->exAppService->getExApp($appId); - - // If ExApp not registered - then it's a "Deploy and Enable" action. Get default_daemon_config, deploy ExApp, register and finally enable - if ($exApp === null) { - $infoXml = $this->exAppService->getLatestExAppInfoFromAppstore($appId); - $defaultDaemonConfigName = $this->config->getAppValue(Application::APP_ID, 'default_daemon_config', ''); - $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName); - if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) { - return new JSONResponse(['data' => ['message' => $this->l10n->t('Only docker-install is supported for now')]], Http::STATUS_INTERNAL_SERVER_ERROR); - } - // 1. Deploy ExApp - if ($this->deployExApp($appId, $infoXml, $daemonConfig)) { - // 2. Register ExApp (container must be already initialized successfully) - if (!$this->registerExApp($appId, $infoXml, $daemonConfig)) { - $this->exAppService->unregisterExApp($appId); // Fallback unregister if failure - return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to register ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } else { - $this->logger->error(sprintf('Failed to deploy %s ExApp', $appId)); - return new JSONResponse([ - 'data' => [ - 'message' => $this->l10n->t('Failed to deploy ExApp'), - ] - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $exApp = $this->exAppService->getExApp($appId); - - // Start ExApp initialization step (to download dynamic content, e.g. models) - if (!$this->service->dispatchExAppInit($exApp->getAppid())) { - return new JSONResponse([ - 'data' => [ - 'message' => $this->l10n->t('Failed to send "init" event to ExApp.'), - ] - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $scopes = $this->exAppApiScopeService->mapScopeGroupsToNames(array_map(function (ExAppScope $exAppScope) { - return $exAppScope->getScopeGroup(); - }, $this->exAppScopeService->getExAppScopes($exApp))); - $auth = []; - return new JSONResponse([ - 'data' => [ - 'daemon_config' => $daemonConfig, - 'systemApp' => $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''), - 'exAppUrl' => $this->service->getExAppUrl($exApp, $exApp->getPort(), $auth), - 'status' => $exApp->getStatus(), - 'scopes' => $scopes, - ] - ]); - } - - $appsWithUpdate = $this->getExAppsWithUpdates(); - $appIdsWithUpdate = array_map(function (array $appWithUpdate) { - return $appWithUpdate['id']; - }, $appsWithUpdate); - - if (in_array($appId, $appIdsWithUpdate)) { - $updateRequired = true; - } - - if (!$this->service->enableExApp($exApp)) { - return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to enable ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); - } + #[PasswordConfirmationRequired] + public function enableApp(string $appId): JSONResponse { + $updateRequired = false; + $exApp = $this->exAppService->getExApp($appId); + // If ExApp is not registered - then it's a "Deploy and Enable" action. + if (!$exApp) { + if (!$this->service->runOccCommand(sprintf("app_api:app:register --force-scopes --silent %s", $appId))) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Error starting install of ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } - - return new JSONResponse(['data' => ['update_required' => $updateRequired]]); - } catch (Exception $e) { - $this->logger->error('Could not enable ExApps', ['exception' => $e]); - return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - private function deployExApp(string $appId, SimpleXMLElement $infoXml, DaemonConfig $daemonConfig): bool { - $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $infoXml); - [$pullResult, $createResult, $startResult] = $this->dockerActions->deployExApp($daemonConfig, $deployParams); - - if (isset($pullResult['error']) || isset($createResult['error']) || isset($startResult['error'])) { - return false; - } - - if (!$this->dockerActions->healthcheckContainer($this->dockerActions->buildExAppContainerName($appId), $daemonConfig)) { - return false; - } - - $auth = []; - $exAppUrl = $this->dockerActions->resolveExAppUrl( - $appId, - $daemonConfig->getProtocol(), - $daemonConfig->getHost(), - $daemonConfig->getDeployConfig(), - (int) explode('=', $deployParams['container_params']['env'][6])[1], - $auth, - ); - if (!$this->service->heartbeatExApp($exAppUrl, $auth)) { - return false; + $elapsedTime = 0; + while ($elapsedTime < 5000000 && !$this->exAppService->getExApp($appId)) { + usleep(150000); // 0.15 + $elapsedTime += 150000; + } + if (!$this->exAppService->getExApp($appId)) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not perform installation of ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); + } + return new JSONResponse([]); } - return true; - } - - private function registerExApp(string $appId, SimpleXMLElement $infoXml, DaemonConfig $daemonConfig): bool { - $exAppInfo = $this->dockerActions->loadExAppInfo($appId, $daemonConfig); - $exApp = $this->exAppService->registerExApp($appId, [ - 'appid' => $exAppInfo['appid'], - 'version' => $exAppInfo['version'], - 'name' => $exAppInfo['name'], - 'daemon_config_name' => $daemonConfig->getName(), - 'port' => (int) $exAppInfo['port'], - 'secret' => $exAppInfo['secret'], - ]); - - if ($exApp === null) { - return false; - } + $appsWithUpdate = $this->getExAppsWithUpdates(); + $appIdsWithUpdate = array_map(function (array $appWithUpdate) { + return $appWithUpdate['id']; + }, $appsWithUpdate); - // Setup system flag - try { - $isSystemApp = $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''); - if (filter_var($exAppInfo['system_app'], FILTER_VALIDATE_BOOLEAN) && !$isSystemApp) { - $this->exAppUsersService->setupSystemAppFlag($exApp->getAppid()); - } - } catch (Exception $e) { - $this->logger->error(sprintf('Error while setting app system flag: %s', $e->getMessage())); - return false; + if (in_array($appId, $appIdsWithUpdate)) { + $updateRequired = true; } - // Register ExApp ApiScopes - $requestedExAppScopeGroups = $this->exAppService->getExAppScopes($exApp, $infoXml, $exAppInfo); - return $this->registerApiScopes($exApp, $requestedExAppScopeGroups); - } - - private function registerApiScopes(ExApp $exApp, array $requestedExAppScopeGroups): bool { - $registeredScopeGroups = []; - foreach ($this->exAppApiScopeService->mapScopeNamesToNumbers($requestedExAppScopeGroups) as $scopeGroup) { - if ($this->exAppScopeService->setExAppScopeGroup($exApp, $scopeGroup)) { - $registeredScopeGroups[] = $scopeGroup; - } + if (!$this->service->enableExApp($exApp)) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to enable ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } - return count($registeredScopeGroups) === count($requestedExAppScopeGroups); + return new JSONResponse(['data' => ['update_required' => $updateRequired]]); } #[PasswordConfirmationRequired] public function disableApp(string $appId): JSONResponse { - return $this->disableApps([$appId]); - } - - #[PasswordConfirmationRequired] - public function disableApps(array $appIds): JSONResponse { - try { - foreach ($appIds as $appId) { - $exApp = $this->exAppService->getExApp($appId); - if ($exApp->getEnabled()) { - if (!$this->service->disableExApp($exApp)) { - return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to disable ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); - } + $exApp = $this->exAppService->getExApp($appId); + if ($exApp) { + if ($exApp->getEnabled()) { + if (!$this->service->disableExApp($exApp)) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to disable ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } } - return new JSONResponse([]); - } catch (Exception $e) { - $this->logger->error('Could not disable ExApp', ['exception' => $e]); - return new JSONResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR); } + return new JSONResponse([]); } /** @@ -612,93 +454,25 @@ public function updateApp(string $appId): JSONResponse { if (!in_array($appId, $appIdsWithUpdate)) { return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not update ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } - $exApp = $this->exAppService->getExApp($appId); - - // TODO: Add error messages on each step failure as in CLI - - // 1. Disable ExApp - if ($exApp->getEnabled()) { - $this->service->disableExApp($exApp); - } - $infoXml = $this->exAppService->getLatestExAppInfoFromAppstore($appId); - $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); - if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) { - return new JSONResponse(['data' => ['message' => $this->l10n->t('Only docker-install is supported for now')]], Http::STATUS_INTERNAL_SERVER_ERROR); + $exAppOldVersion = $this->exAppService->getExApp($appId)->getVersion(); + if (!$this->service->runOccCommand(sprintf("app_api:app:update --force-scopes --silent %s", $appId))) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Error starting update of ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } - $this->dockerActions->initGuzzleClient($daemonConfig); - $containerInfo = $this->dockerActions->inspectContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($appId)); - - $deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $infoXml, [ - 'container_info' => $containerInfo, - ]); - // 2. Update ExApp container (deploy new version) - $this->dockerActions->updateExApp($daemonConfig, $deployParams); - - // 3. Update ExApp info on NC side - $exAppInfo = $this->dockerActions->loadExAppInfo($appId, $daemonConfig); - $this->exAppService->updateExAppInfo($exApp, $exAppInfo); - - // 4. Update ExApp ApiScopes - $this->upgradeExAppScopes($exApp, $infoXml); - - $exApp = $this->exAppService->getExApp($appId); - // 5. Heartbeat ExApp - $auth = []; - $exAppUrl = $this->dockerActions->resolveExAppUrl( - $appId, - $daemonConfig->getProtocol(), - $daemonConfig->getHost(), - $daemonConfig->getDeployConfig(), - (int) $exAppInfo['port'], - $auth - ); - if ($this->service->heartbeatExApp($exAppUrl, $auth)) { - // 6. Dispatch init step on ExApp side - if (!$this->service->dispatchExAppInit($exApp->getAppid(), true)) { - return new JSONResponse([ - 'data' => [ - 'message' => $this->l10n->t('Failed to send "init" event to ExApp.'), - ] - ], Http::STATUS_INTERNAL_SERVER_ERROR); + $elapsedTime = 0; + while ($elapsedTime < 5000000) { + $exApp = $this->exAppService->getExApp($appId); + if ($exApp && ($exApp->getStatus()['type'] == 'update' || $exApp->getVersion() !== $exAppOldVersion)) { + break; } + usleep(150000); // 0.15 + $elapsedTime += 150000; } - - $scopes = $this->exAppApiScopeService->mapScopeGroupsToNames(array_map(function (ExAppScope $exAppScope) { - return $exAppScope->getScopeGroup(); - }, $this->exAppScopeService->getExAppScopes($exApp))); - return new JSONResponse([ - 'data' => [ - 'appid' => $appId, - 'status' => ['progress' => 0], - 'systemApp' => filter_var($exAppInfo['system_app'], FILTER_VALIDATE_BOOLEAN), - 'exAppUrl' => $exAppUrl, - 'scopes' => $scopes, - ] - ]); - } - - public function enableExApp(string $appId): JSONResponse { - $exApp = $this->exAppService->getExApp($appId); - if (!$this->service->enableExApp($exApp)) { - return new JSONResponse(['data' => ['message' => $this->l10n->t('Failed to enable ExApp')]]); + if ($elapsedTime >= 5000000) { + return new JSONResponse(['data' => ['message' => $this->l10n->t('Could not perform update of ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } - - $auth = []; - return new JSONResponse([ - 'data' => [ - 'appid' => $appId, - 'systemApp' => $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''), - 'exAppUrl' => $this->service->getExAppUrl($exApp, $exApp->getPort(), $auth), - ] - ]); - } - - private function upgradeExAppScopes(ExApp $exApp, SimpleXMLElement $infoXml): void { - $newExAppScopes = $this->exAppService->getExAppScopes($exApp, $infoXml); - $newExAppScopes = $this->exAppApiScopeService->mapScopeNamesToNumbers($newExAppScopes); - $this->exAppScopeService->updateExAppScopes($exApp, $newExAppScopes); + return new JSONResponse(); } /** @@ -707,22 +481,23 @@ private function upgradeExAppScopes(ExApp $exApp, SimpleXMLElement $infoXml): vo #[PasswordConfirmationRequired] public function uninstallApp(string $appId, bool $removeContainer = true, bool $removeData = false): JSONResponse { $exApp = $this->exAppService->getExApp($appId); - if ($exApp->getEnabled()) { - $this->service->disableExApp($exApp); - } + if ($exApp) { + if ($exApp->getEnabled()) { + $this->service->disableExApp($exApp); + } - $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); - if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { - $this->dockerActions->initGuzzleClient($daemonConfig); - if ($removeContainer) { - $this->dockerActions->removePrevExAppContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($appId)); - if ($removeData) { - $this->dockerActions->removeVolume($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppVolumeName($appId)); + $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($exApp->getDaemonConfigName()); + if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) { + $this->dockerActions->initGuzzleClient($daemonConfig); + if ($removeContainer) { + $this->dockerActions->removeContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($appId)); + if ($removeData) { + $this->dockerActions->removeVolume($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppVolumeName($appId)); + } } } + $this->exAppService->unregisterExApp($appId); } - - $this->exAppService->unregisterExApp($appId); return new JSONResponse(); } diff --git a/lib/Controller/OCSApiController.php b/lib/Controller/OCSApiController.php index c08252a7..b328c47f 100644 --- a/lib/Controller/OCSApiController.php +++ b/lib/Controller/OCSApiController.php @@ -66,8 +66,12 @@ public function getNCUsersList(): DataResponse { #[AppAPIAuth] #[PublicPage] #[NoCSRFRequired] - public function setAppProgress(string $appId, int $progress, string $error = ''): DataResponse { - $this->service->setAppInitProgress($appId, $progress, $error); + public function setAppInitProgress(string $appId, int $progress, string $error = ''): DataResponse { + $exApp = $this->exAppService->getExApp($appId); + if (!$exApp) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + $this->service->setAppInitProgress($exApp, $progress, $error); return new DataResponse(); } } diff --git a/lib/Controller/OCSExAppController.php b/lib/Controller/OCSExAppController.php index 30f4319b..d68d7c02 100644 --- a/lib/Controller/OCSExAppController.php +++ b/lib/Controller/OCSExAppController.php @@ -12,7 +12,6 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\IRequest; @@ -26,26 +25,31 @@ public function __construct( parent::__construct(Application::APP_ID, $request); } - /** - * @throws OCSBadRequestException - */ #[NoCSRFRequired] public function getExAppsList(string $list = 'enabled'): DataResponse { if (!in_array($list, ['all', 'enabled'])) { - throw new OCSBadRequestException(); + return new DataResponse([], Http::STATUS_BAD_REQUEST); } return new DataResponse($this->exAppService->getExAppsList($list), Http::STATUS_OK); } + #[NoCSRFRequired] + public function getExApp(string $appId): DataResponse { + $exApp = $this->exAppService->getExApp($appId); + if (!$exApp) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + return new DataResponse($this->exAppService->formatExAppInfo($exApp), Http::STATUS_OK); + } + /** - * @throws OCSNotFoundException * @throws OCSBadRequestException */ #[NoCSRFRequired] public function setExAppEnabled(string $appId, int $enabled): DataResponse { $exApp = $this->exAppService->getExApp($appId); - if ($exApp === null) { - throw new OCSNotFoundException('ExApp not found'); + if (!$exApp) { + return new DataResponse([], Http::STATUS_NOT_FOUND); } if (filter_var($enabled, FILTER_VALIDATE_BOOL)) { diff --git a/lib/Db/DaemonConfig.php b/lib/Db/DaemonConfig.php index 0912551f..d1b4f9fa 100644 --- a/lib/Db/DaemonConfig.php +++ b/lib/Db/DaemonConfig.php @@ -17,14 +17,12 @@ * @method string getDisplayName() * @method string getProtocol() * @method string getHost() - * @method string getPort() * @method array getDeployConfig() * @method void setAcceptsDeployId(string $acceptsDeployId) * @method void setName(string $name) * @method void setDisplayName(string $displayName) * @method void setProtocol(string $protocol) * @method void setHost(string $host) - * @method void setPort(string $port) * @method void setDeployConfig(array $deployConfig) */ class DaemonConfig extends Entity implements JsonSerializable { @@ -33,7 +31,6 @@ class DaemonConfig extends Entity implements JsonSerializable { protected $acceptsDeployId; protected $protocol; protected $host; - protected $port; protected $deployConfig; /** @@ -45,7 +42,6 @@ public function __construct(array $params = []) { $this->addType('displayName', 'string'); $this->addType('protocol', 'string'); $this->addType('host', 'string'); - $this->addType('port', 'string'); $this->addType('deployConfig', 'json'); if (isset($params['id'])) { diff --git a/lib/DeployActions/DockerActions.php b/lib/DeployActions/DockerActions.php index 416bb564..c25f46e7 100644 --- a/lib/DeployActions/DockerActions.php +++ b/lib/DeployActions/DockerActions.php @@ -10,17 +10,15 @@ use OCA\AppAPI\AppInfo\Application; use OCA\AppAPI\Db\DaemonConfig; +use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Service\AppAPICommonService; -use OCA\AppAPI\Service\DaemonConfigService; use OCA\AppAPI\Service\ExAppService; use OCP\App\IAppManager; use OCP\ICertificateManager; use OCP\IConfig; use OCP\IURLGenerator; -use OCP\Security\ISecureRandom; use Psr\Log\LoggerInterface; -use SimpleXMLElement; class DockerActions implements IDeployActions { public const DOCKER_API_VERSION = 'v1.41'; @@ -40,17 +38,16 @@ class DockerActions implements IDeployActions { public const APP_API_HAPROXY_USER = 'app_api_haproxy_user'; private Client $guzzleClient; + private bool $useSocket = false; # for `pullImage` function, to detect can be stream used or not. public function __construct( private readonly LoggerInterface $logger, private readonly IConfig $config, private readonly ICertificateManager $certificateManager, private readonly IAppManager $appManager, - private readonly ISecureRandom $random, private readonly IURLGenerator $urlGenerator, private readonly AppAPICommonService $service, - private readonly ExAppService $exAppService, - private readonly DaemonConfigService $daemonConfigService, + private readonly ExAppService $exAppService, ) { } @@ -58,49 +55,46 @@ public function getAcceptsDeployId(): string { return 'docker-install'; } - /** - * Pull image, create and start container - */ - public function deployExApp(DaemonConfig $daemonConfig, array $params = []): array { - if ($daemonConfig->getAcceptsDeployId() !== 'docker-install') { - return [['error' => 'Only docker-install is supported for now.'], null, null]; - } - - if (isset($params['image_params'])) { - $imageParams = $params['image_params']; - } else { - return [['error' => 'Missing image_params.'], null, null]; + public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string { + if (!isset($params['image_params'])) { + return 'Missing image_params.'; } + $imageParams = $params['image_params']; - if (isset($params['container_params'])) { - $containerParams = $params['container_params']; - } else { - return [['error' => 'Missing container_params.'], null, null]; + if (!isset($params['container_params'])) { + return 'Missing container_params.'; } + $containerParams = $params['container_params']; $dockerUrl = $this->buildDockerUrl($daemonConfig); $this->initGuzzleClient($daemonConfig); - $pullResult = $this->pullContainer($dockerUrl, $imageParams); - if (isset($pullResult['error'])) { - return [$pullResult, null, null]; + $this->exAppService->setAppDeployProgress($exApp, 0); + $result = $this->pullImage($dockerUrl, $imageParams, $exApp, 0, 94); + if ($result) { + return $result; } + $this->exAppService->setAppDeployProgress($exApp, 95); $containerInfo = $this->inspectContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); if (isset($containerInfo['Id'])) { - [$stopResult, $removeResult] = $this->removePrevExAppContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); - if (isset($stopResult['error']) || isset($removeResult['error'])) { - return [$pullResult, $stopResult, $removeResult]; + $result = $this->removeContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); + if ($result) { + return $result; } } - - $createResult = $this->createContainer($dockerUrl, $imageParams, $containerParams); - if (isset($createResult['error'])) { - return [null, $createResult, null]; + $this->exAppService->setAppDeployProgress($exApp, 96); + $result = $this->createContainer($dockerUrl, $imageParams, $containerParams); + if (isset($result['error'])) { + return $result['error']; } - - $startResult = $this->startContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); - return [$pullResult, $createResult, $startResult]; + $this->exAppService->setAppDeployProgress($exApp, 97); + $result = $this->startContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); + if (isset($result['error'])) { + return $result['error']; + } + $this->exAppService->setAppDeployProgress($exApp, 100); + return ''; } public function buildApiUrl(string $dockerUrl, string $route): string { @@ -187,33 +181,100 @@ public function stopContainer(string $dockerUrl, string $containerId): array { } } - public function removeContainer(string $dockerUrl, string $containerId): array { - $url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s', $containerId)); + public function removeContainer(string $dockerUrl, string $containerId): string { + $url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s?force=true', $containerId)); try { $response = $this->guzzleClient->delete($url); - return ['success' => $response->getStatusCode() === 204]; + $this->logger->debug(sprintf('StatusCode of container removal: %d', $response->getStatusCode())); + if ($response->getStatusCode() === 200 || $response->getStatusCode() === 204) { + return ''; + } } catch (GuzzleException $e) { if ($e->getCode() === 409) { // "removal of container ... is already in progress" - return ['success' => true]; + return ''; } - $this->logger->error('Failed to stop container', ['exception' => $e]); + $this->logger->error('Failed to remove container', ['exception' => $e]); error_log($e->getMessage()); - return ['error' => 'Failed to stop container']; } + return sprintf('Failed to remove container: %s', $containerId); } - public function pullContainer(string $dockerUrl, array $params): array { + public function pullImage(string $dockerUrl, array $params, ExApp $exApp, int $startPercent, int $maxPercent): string { + # docs: https://github.com/docker/compose/blob/main/pkg/compose/pull.go + $layerInProgress = ['preparing', 'waiting', 'pulling fs layer', 'download', 'extracting', 'verifying checksum']; + $layerFinished = ['already exists', 'pull complete']; + $disableProgressTracking = false; $imageId = $this->buildImageName($params); $url = $this->buildApiUrl($dockerUrl, sprintf('images/create?fromImage=%s', urlencode($imageId))); $this->logger->info(sprintf('Pulling ExApp Image: %s', $imageId)); try { - $response = $this->guzzleClient->post($url); - $this->logger->info(sprintf('Pull ExApp image result=%d for %s', $response->getStatusCode(), $imageId)); - return ['success' => $response->getStatusCode() === 200]; + if ($this->useSocket) { + $response = $this->guzzleClient->post($url); + } else { + $response = $this->guzzleClient->post($url, ['stream' => true]); + } + if ($response->getStatusCode() !== 200) { + return sprintf('Pulling ExApp Image: %s return status code: %d', $imageId, $response->getStatusCode()); + } + if ($this->useSocket) { + return ''; + } + $lastPercent = $startPercent; + $layers = []; + $buffer = ''; + $responseBody = $response->getBody(); + while (!$responseBody->eof()) { + $buffer .= $responseBody->read(1024); + try { + while (($newlinePos = strpos($buffer, "\n")) !== false) { + $line = substr($buffer, 0, $newlinePos); + $buffer = substr($buffer, $newlinePos + 1); + $jsonLine = json_decode(trim($line)); + if ($jsonLine) { + if (isset($jsonLine->id) && isset($jsonLine->status)) { + $layerId = $jsonLine->id; + $status = strtolower($jsonLine->status); + foreach ($layerInProgress as $substring) { + if (str_contains($status, $substring)) { + $layers[$layerId] = false; + break; + } + } + foreach ($layerFinished as $substring) { + if (str_contains($status, $substring)) { + $layers[$layerId] = true; + break; + } + } + } + } else { + $this->logger->warning( + sprintf("Progress tracking of image pulling(%s) disabled, error: %d, data: %s", $exApp->getAppid(), json_last_error(), $line) + ); + $disableProgressTracking = true; + } + } + } catch (Exception $e) { + $this->logger->warning( + sprintf("Progress tracking of image pulling(%s) disabled, exception: %s", $exApp->getAppid(), $e->getMessage()), ['exception' => $e] + ); + $disableProgressTracking = true; + } + if (!$disableProgressTracking) { + $completedLayers = count(array_filter($layers)); + $totalLayers = count($layers); + $newLastPercent = intval($totalLayers > 0 ? ($completedLayers / $totalLayers) * ($maxPercent - $startPercent) : 0); + if ($lastPercent != $newLastPercent) { + $this->exAppService->setAppDeployProgress($exApp, $newLastPercent); + $lastPercent = $newLastPercent; + } + } + } + return ''; } catch (GuzzleException $e) { $this->logger->error('Failed to pull image', ['exception' => $e]); error_log($e->getMessage()); - return ['error' => 'Failed to pull image.']; + return 'Failed to pull image, GuzzleException occur.'; } } @@ -292,62 +353,19 @@ public function ping(string $dockerUrl): bool { return false; } - /** - * @param DaemonConfig $daemonConfig - * @param array $params Deploy params (image_params, container_params) - * - * @return array - */ - public function updateExApp(DaemonConfig $daemonConfig, array $params = []): array { - $dockerUrl = $this->buildDockerUrl($daemonConfig); - - $pullResult = $this->pullContainer($dockerUrl, $params['image_params']); - if (isset($pullResult['error'])) { - return [$pullResult, null, null, null, null]; - } - - [$stopResult, $removeResult] = $this->removePrevExAppContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); - if (isset($stopResult['error'])) { - return [$pullResult, $stopResult, null, null, null]; - } - if (isset($removeResult['error'])) { - return [$pullResult, $stopResult, $removeResult, null, null]; - } - - $createResult = $this->createContainer($dockerUrl, $params['image_params'], $params['container_params']); - if (isset($createResult['error'])) { - return [$pullResult, $stopResult, $removeResult, $createResult, null]; - } - - $startResult = $this->startContainer($dockerUrl, $this->buildExAppContainerName($params['container_params']['name'])); - return [$pullResult, $stopResult, $removeResult, $createResult, $startResult]; - } - - public function removePrevExAppContainer(string $dockerUrl, string $containerId): array { - $stopResult = $this->stopContainer($dockerUrl, $containerId); - if (isset($stopResult['error'])) { - return [$stopResult, null]; - } - - $removeResult = $this->removeContainer($dockerUrl, $containerId); - return [$stopResult, $removeResult]; - } - - public function buildDeployParams(DaemonConfig $daemonConfig, SimpleXMLElement $infoXml, array $params = []): array { - $appId = (string) $infoXml->id; + public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo, array $params = []): array { + $appId = (string) $appInfo['id']; + $externalApp = $appInfo['external-app']; $deployConfig = $daemonConfig->getDeployConfig(); // If update process if (isset($params['container_info'])) { $containerInfo = $params['container_info']; $oldEnvs = $this->extractDeployEnvs((array) $containerInfo['Config']['Env']); - $port = $oldEnvs['APP_PORT'] ?? $this->exAppService->getExAppFreePort(); - $secret = $oldEnvs['APP_SECRET']; $storage = $oldEnvs['APP_PERSISTENT_STORAGE']; // Preserve previous device requests (GPU) $deviceRequests = $containerInfo['HostConfig']['DeviceRequests'] ?? []; } else { - $port = $this->exAppService->getExAppFreePort(); if (isset($deployConfig['gpu']) && filter_var($deployConfig['gpu'], FILTER_VALIDATE_BOOLEAN)) { $deviceRequests = $this->buildDefaultGPUDeviceRequests(); } else { @@ -357,26 +375,26 @@ public function buildDeployParams(DaemonConfig $daemonConfig, SimpleXMLElement $ } $imageParams = [ - 'image_src' => (string) ($infoXml->xpath('external-app/docker-install/registry')[0] ?? 'docker.io'), - 'image_name' => (string) ($infoXml->xpath('external-app/docker-install/image')[0] ?? $appId), - 'image_tag' => (string) ($infoXml->xpath('external-app/docker-install/image-tag')[0] ?? 'latest'), + 'image_src' => (string) ($externalApp['docker-install']['registry'] ?? 'docker.io'), + 'image_name' => (string) ($externalApp['docker-install']['image'] ?? $appId), + 'image_tag' => (string) ($externalApp['docker-install']['image-tag'] ?? 'latest'), ]; $envs = $this->buildDeployEnvs([ 'appid' => $appId, - 'name' => (string) $infoXml->name, - 'version' => (string) $infoXml->version, + 'name' => (string) $appInfo['name'], + 'version' => (string) $appInfo['version'], 'host' => $this->service->buildExAppHost($deployConfig), - 'port' => $port, + 'port' => $appInfo['port'], 'storage' => $storage, - 'system_app' => filter_var((string) $infoXml->xpath('external-app/system')[0], FILTER_VALIDATE_BOOLEAN), - 'secret' => $secret ?? $this->random->generate(128), + 'system_app' => filter_var((string) $externalApp['system'], FILTER_VALIDATE_BOOLEAN), + 'secret' => $appInfo['secret'], ], $deployConfig); $containerParams = [ 'name' => $appId, 'hostname' => $appId, - 'port' => $port, + 'port' => $appInfo['port'], 'net' => $deployConfig['net'] ?? 'host', 'env' => $envs, 'deviceRequests' => $deviceRequests, @@ -505,6 +523,7 @@ public function initGuzzleClient(DaemonConfig $daemonConfig): void { CURLOPT_UNIX_SOCKET_PATH => $daemonConfig->getHost(), ], ]; + $this->useSocket = true; } elseif ($daemonConfig->getProtocol() === 'https') { $guzzleParams = $this->setupCerts($guzzleParams); } diff --git a/lib/DeployActions/IDeployActions.php b/lib/DeployActions/IDeployActions.php index e875ecaa..c6470191 100644 --- a/lib/DeployActions/IDeployActions.php +++ b/lib/DeployActions/IDeployActions.php @@ -5,6 +5,7 @@ namespace OCA\AppAPI\DeployActions; use OCA\AppAPI\Db\DaemonConfig; +use OCA\AppAPI\Db\ExApp; /** * Base interface for AppAPI ExApp deploy actions @@ -20,33 +21,24 @@ public function getAcceptsDeployId(): string; /** * Deploy ExApp to the target daemon * + * @param ExApp $exApp * @param DaemonConfig $daemonConfig * @param array $params * * @return mixed */ - public function deployExApp(DaemonConfig $daemonConfig, array $params = []): mixed; - - /** - * Update existing deployed ExApp on target daemon - * - * @param DaemonConfig $daemonConfig - * @param array $params - * - * @return mixed - */ - public function updateExApp(DaemonConfig $daemonConfig, array $params = []): mixed; + public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string; /** * Build required info for ExApp deployment * * @param DaemonConfig $daemonConfig - * @param \SimpleXMLElement $infoXml + * @param array $appInfo * @param array $params * * @return mixed */ - public function buildDeployParams(DaemonConfig $daemonConfig, \SimpleXMLElement $infoXml, array $params = []): mixed; + public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo, array $params = []): mixed; /** * Build required deploy environment variables diff --git a/lib/DeployActions/ManualActions.php b/lib/DeployActions/ManualActions.php index 754bb633..363678b8 100644 --- a/lib/DeployActions/ManualActions.php +++ b/lib/DeployActions/ManualActions.php @@ -5,30 +5,31 @@ namespace OCA\AppAPI\DeployActions; use OCA\AppAPI\Db\DaemonConfig; +use OCA\AppAPI\Db\ExApp; +use OCA\AppAPI\Service\ExAppService; /** * Manual deploy actions for development. */ class ManualActions implements IDeployActions { - public function __construct() { + public function __construct( + private readonly ExAppService $exAppService, + ) { } public function getAcceptsDeployId(): string { return 'manual-install'; } - public function deployExApp(DaemonConfig $daemonConfig, array $params = []): mixed { + public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string { // Not implemented. Deploy is done manually. - return null; - } - - public function updateExApp(DaemonConfig $daemonConfig, array $params = []): mixed { - // Not implemented. Update is done manually. - return null; + $this->exAppService->setAppDeployProgress($exApp, 0); + $this->exAppService->setAppDeployProgress($exApp, 100); + return ''; } - public function buildDeployParams(DaemonConfig $daemonConfig, $infoXml, array $params = []): mixed { + public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo, array $params = []): mixed { // Not implemented. Deploy is done manually. return null; } diff --git a/lib/Service/AppAPIService.php b/lib/Service/AppAPIService.php index 7d118c06..88aca6fe 100644 --- a/lib/Service/AppAPIService.php +++ b/lib/Service/AppAPIService.php @@ -186,11 +186,8 @@ public function validateExAppRequestToNC(IRequest $request, bool $isDav = false) if ($authValid) { if (!$exApp->getEnabled()) { - // If ExApp is in initializing state, it is disabled yet, so we allow requests in such case - if (!isset($exApp->getStatus()['progress'])) { - $this->logger->error(sprintf('ExApp with appId %s is disabled (%s)', $request->getHeader('EX-APP-ID'), $request->getRequestUri())); - return false; - } + $this->logger->error(sprintf('ExApp with appId %s is disabled (%s)', $request->getHeader('EX-APP-ID'), $request->getRequestUri())); + return false; } if (!$this->handleExAppVersionChange($request, $exApp)) { return false; @@ -370,7 +367,6 @@ public function handleExAppVersionChange(IRequest $request, ExApp $exApp): bool } public function dispatchExAppInitInternal(ExApp $exApp): void { - // start in background in a separate process $auth = []; $initUrl = $this->getExAppUrl($exApp, $exApp->getPort(), $auth) . '/init'; $options = [ @@ -383,14 +379,16 @@ public function dispatchExAppInitInternal(ExApp $exApp): void { $options['auth'] = $auth; } + $this->setAppInitProgress($exApp, 0); + $this->exAppService->enableExAppInternal($exApp); try { $this->client->post($initUrl, $options); } catch (\Exception $e) { $statusCode = $e->getCode(); if (($statusCode === Http::STATUS_NOT_IMPLEMENTED) || ($statusCode === Http::STATUS_NOT_FOUND)) { - $this->setAppInitProgress($exApp->getAppid(), 100); + $this->setAppInitProgress($exApp, 100); } else { - $this->setAppInitProgress($exApp->getAppid(), 0, $e->getMessage()); + $this->setAppInitProgress($exApp, 0, $e->getMessage()); } } } @@ -398,17 +396,15 @@ public function dispatchExAppInitInternal(ExApp $exApp): void { /** * Dispatch ExApp initialization step, that may take a long time to display the progress of initialization. */ - public function dispatchExAppInit(string $appId, bool $update = false): bool { - $this->setAppInitProgress($appId, 0, '', $update, true); + public function runOccCommand(string $command): bool { $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; - $args = ['app_api:app:dispatch_init', $appId]; $args = array_map(function ($arg) { return escapeshellarg($arg); - }, $args); + }, explode(' ', $command)); $args[] = '--no-ansi --no-warnings'; $args = implode(' ', $args); $occDirectory = null; @@ -418,7 +414,7 @@ public function dispatchExAppInit(string $appId, bool $update = false): bool { $this->logger->info(sprintf('Calling occ(directory=%s): %s', $occDirectory ?? 'null', $args)); $process = proc_open('php console.php ' . $args, $descriptors, $pipes, $occDirectory); if (!is_resource($process)) { - $this->logger->error(sprintf('ExApp %s dispatch_init failed(occDirectory=%s).', $appId, $occDirectory ?? 'null')); + $this->logger->error(sprintf('Error calling occ(directory=%s): %s', $occDirectory ?? 'null', $args)); return false; } fclose($pipes[0]); @@ -525,8 +521,7 @@ public function enableExApp(ExApp $exApp): bool { /** * Disable ExApp. Sends request to ExApp to update enabled state. - * If request fails, ExApp keep disabled in database. - * Removes ExApp from cache. + * If request fails, disables ExApp in database, cache. */ public function disableExApp(ExApp $exApp): bool { $result = true; @@ -545,40 +540,28 @@ public function disableExApp(ExApp $exApp): bool { return $result; } - /** - * Update ExApp status during initialization step. - * Active status is set when progress reached 100%. - */ - public function setAppInitProgress(string $appId, int $progress, string $error = '', bool $update = false, bool $init = false): void { - $exApp = $this->exAppService->getExApp($appId); - $status = $exApp->getStatus(); - if ($init) { - $status['init_start_time'] = time(); + public function setAppInitProgress(ExApp $exApp, int $progress, string $error = ''): void { + if ($progress < 0 || $progress > 100) { + throw new \InvalidArgumentException('Invalid ExApp init status progress value'); } - - if ($update) { - // Set active=false during update action, for register it already false - $status['active'] = false; - } - - if ($status['active']) { + $status = $exApp->getStatus(); + if ($progress !== 0 && isset($status['init']) && $status['init'] === 100) { return; } - if ($error !== '') { - $this->logger->error(sprintf('ExApp %s initialization failed. Error: %s', $appId, $error)); + $this->logger->error(sprintf('ExApp %s initialization failed. Error: %s', $exApp->getAppid(), $error)); $status['error'] = $error; - unset($status['progress']); - unset($status['init_start_time']); } else { - if ($progress >= 0 && $progress < 100) { - $status['progress'] = $progress; - } elseif ($progress === 100) { - unset($status['progress']); - } else { - throw new \InvalidArgumentException('Invalid ExApp status progress value'); + if ($progress === 0) { + $status['action'] = 'init'; + $status['init_start_time'] = time(); + unset($status['error']); } - $status['active'] = $progress === 100; + $status['init'] = $progress; + } + if ($progress === 100) { + $status['action'] = ''; + $status['type'] = ''; } $exApp->setStatus($status); $exApp->setLastCheckTime(time()); @@ -598,7 +581,7 @@ public function removeExAppsByDaemonConfigName(DaemonConfig $daemonConfig): void $this->disableExApp($exApp); if ($daemonConfig->getAcceptsDeployId() === 'docker-install') { $this->dockerActions->initGuzzleClient($daemonConfig); - $this->dockerActions->removePrevExAppContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($exApp->getAppid())); + $this->dockerActions->removeContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($exApp->getAppid())); $this->dockerActions->removeVolume($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppVolumeName($exApp->getAppid())); } $this->exAppService->unregisterExApp($exApp->getAppid()); diff --git a/lib/Service/ExAppApiScopeService.php b/lib/Service/ExAppApiScopeService.php index c3eaebd3..52f9f061 100644 --- a/lib/Service/ExAppApiScopeService.php +++ b/lib/Service/ExAppApiScopeService.php @@ -21,9 +21,9 @@ class ExAppApiScopeService { private ICache $cache; public function __construct( - private LoggerInterface $logger, - private ExAppApiScopeMapper $mapper, - ICacheFactory $cacheFactory, + private readonly LoggerInterface $logger, + private readonly ExAppApiScopeMapper $mapper, + ICacheFactory $cacheFactory, ) { $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_apps_api_scopes'); } diff --git a/lib/Service/ExAppScopesService.php b/lib/Service/ExAppScopesService.php index 935761d7..55133579 100644 --- a/lib/Service/ExAppScopesService.php +++ b/lib/Service/ExAppScopesService.php @@ -20,9 +20,9 @@ class ExAppScopesService { private ICache $cache; public function __construct( - private LoggerInterface $logger, - private ExAppScopeMapper $mapper, - ICacheFactory $cacheFactory, + private readonly LoggerInterface $logger, + private readonly ExAppScopeMapper $mapper, + ICacheFactory $cacheFactory, ) { $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_apps_scopes'); } @@ -112,7 +112,7 @@ public function removeExAppScope(ExApp $exApp, int $apiScope): bool { } } - public function updateExAppScopes(ExApp $exApp, array $newExAppScopes): bool { + public function registerExAppScopes(ExApp $exApp, array $newExAppScopes): bool { $currentExAppScopes = array_map(function (ExAppScope $exAppScope) { return $exAppScope->getScopeGroup(); }, $this->getExAppScopes($exApp)); diff --git a/lib/Service/ExAppService.php b/lib/Service/ExAppService.php index 173aa809..89703d52 100644 --- a/lib/Service/ExAppService.php +++ b/lib/Service/ExAppService.php @@ -7,6 +7,7 @@ use OCA\AppAPI\AppInfo\Application; use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Db\ExAppMapper; +use OCA\AppAPI\Db\ExAppScope; use OCA\AppAPI\Fetcher\ExAppArchiveFetcher; use OCA\AppAPI\Fetcher\ExAppFetcher; use OCA\AppAPI\Service\ProvidersAI\SpeechToTextService; @@ -25,7 +26,6 @@ use OCP\ICacheFactory; use OCP\IUser; use OCP\IUserManager; -use OCP\Security\ISecureRandom; use Psr\Log\LoggerInterface; use SimpleXMLElement; @@ -36,13 +36,13 @@ class ExAppService { public function __construct( private readonly LoggerInterface $logger, ICacheFactory $cacheFactory, - private readonly ISecureRandom $random, private readonly IUserManager $userManager, private readonly ExAppFetcher $exAppFetcher, private readonly ExAppArchiveFetcher $exAppArchiveFetcher, private readonly ExAppMapper $exAppMapper, private readonly ExAppUsersService $exAppUsersService, private readonly ExAppScopesService $exAppScopesService, + private readonly ExAppApiScopeService $exAppApiScopeService, private readonly TopMenuService $topMenuService, private readonly InitialStateService $initialStateService, private readonly ScriptsService $scriptsService, @@ -76,32 +76,25 @@ public function getExApp(string $appId): ?ExApp { return null; } - /** - * Register ExApp or update if already exists - * - * @param string $appId - * @param array $appData [version, name, daemon_config_id, protocol, host, port, secret] - * @return ExApp|null - */ - public function registerExApp(string $appId, array $appData): ?ExApp { + public function registerExApp(array $appInfo): ?ExApp { $exApp = new ExApp([ - 'appid' => $appId, - 'version' => $appData['version'], - 'name' => $appData['name'], - 'daemon_config_name' => $appData['daemon_config_name'], - 'port' => $appData['port'], - 'secret' => $appData['secret'] !== '' ? $appData['secret'] : $this->random->generate(128), - 'status' => json_encode(['active' => false, 'progress' => 0]), + 'appid' => $appInfo['id'], + 'version' => $appInfo['version'], + 'name' => $appInfo['name'], + 'daemon_config_name' => $appInfo['daemon_config_name'], + 'port' => $appInfo['port'], + 'secret' => $appInfo['secret'], + 'status' => json_encode(['deploy' => 0, 'init' => 0, 'action' => '', 'type' => 'install']), 'created_time' => time(), 'last_check_time' => time(), ]); try { $this->exAppMapper->insert($exApp); - $exApp = $this->exAppMapper->findByAppId($appId); - $this->cache->set('/exApp_' . $appId, $exApp, self::CACHE_TTL); + $exApp = $this->exAppMapper->findByAppId($appInfo['id']); + $this->cache->set('/exApp_' . $appInfo['id'], $exApp, self::CACHE_TTL); return $exApp; } catch (Exception | MultipleObjectsReturnedException | DoesNotExistException $e) { - $this->logger->error(sprintf('Error while registering ExApp %s: %s', $appId, $e->getMessage())); + $this->logger->error(sprintf('Error while registering ExApp %s: %s', $appInfo['id'], $e->getMessage())); return null; } } @@ -185,14 +178,7 @@ public function getExAppsList(string $list = 'enabled'): array { } $exApps = array_map(function (ExApp $exApp) { - return [ - 'id' => $exApp->getAppid(), - 'name' => $exApp->getName(), - 'version' => $exApp->getVersion(), - 'enabled' => filter_var($exApp->getEnabled(), FILTER_VALIDATE_BOOLEAN), - 'last_check_time' => $exApp->getLastCheckTime(), - 'system' => $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''), - ]; + return $this->formatExAppInfo($exApp); }, $exApps); } catch (Exception $e) { $this->logger->error(sprintf('Error while getting ExApps list. Error: %s', $e->getMessage()), ['exception' => $e]); @@ -201,6 +187,21 @@ public function getExAppsList(string $list = 'enabled'): array { return $exApps; } + public function formatExAppInfo(ExApp $exApp): array { + return [ + 'id' => $exApp->getAppid(), + 'name' => $exApp->getName(), + 'version' => $exApp->getVersion(), + 'enabled' => filter_var($exApp->getEnabled(), FILTER_VALIDATE_BOOLEAN), + 'last_check_time' => $exApp->getLastCheckTime(), + 'system' => $this->exAppUsersService->exAppUserExists($exApp->getAppid(), ''), + 'status' => $exApp->getStatus(), + 'scopes' => $this->exAppApiScopeService->mapScopeGroupsToNames(array_map(function (ExAppScope $exAppScope) { + return $exAppScope->getScopeGroup(); + }, $this->exAppScopesService->getExAppScopes($exApp))), + ]; + } + public function getNCUsersList(): ?array { return array_map(function (IUser $user) { return $user->getUID(); @@ -238,43 +239,12 @@ public function updateExApp(ExApp $exApp, array $fields = ['version', 'name', 'p $this->cache->set('/exApp_' . $exApp->getAppid(), $exApp, self::CACHE_TTL); return true; } catch (Exception $e) { - $this->logger->error(sprintf('Failed to update "%s" ExApp info.', $exApp->getAppid(), ), ['exception' => $e]); + $this->logger->error(sprintf('Failed to update "%s" ExApp info.', $exApp->getAppid()), ['exception' => $e]); $this->resetCaches(); } return false; } - public function getExAppScopes(ExApp $exApp, ?SimpleXMLElement $infoXml = null, array $jsonInfo = []): ?array { - if (isset($jsonInfo['scopes'])) { - if (isset($jsonInfo['scopes']['required'])) { - return $jsonInfo['scopes']['required']; // Will be removed in AppAPI 2.1.0 version - } - return $jsonInfo['scopes']; - } - - if ($infoXml === null) { - $exAppInfo = $this->getExAppInfoFromAppstore($exApp); - if (isset($exAppInfo)) { - $infoXml = $exAppInfo; - } - } - - if (isset($infoXml)) { - $oldFormatScopes = $infoXml->xpath('external-app/scopes/required'); - if ($oldFormatScopes !== false) { - $scopes = (array) $oldFormatScopes[0]->value; - return array_values($scopes); // Will be removed in AppAPI 2.2.0 version - } - $scopes = $infoXml->xpath('external-app/scopes'); - if ($scopes !== false) { - $scopes = (array) $scopes[0]; - return array_values($scopes); - } - } - - return ['error' => 'Failed to get ExApp requested scopes.']; - } - /** * Get info from App Store releases for specific ExApp and its current version */ @@ -315,4 +285,89 @@ private function resetCaches(): void { $this->translationService->resetCacheEnabled(); $this->settingsService->resetCacheEnabled(); } + + public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo): array { + if ($jsonInfo !== null) { + $appInfo = json_decode($jsonInfo, true); + # fill 'id' if it is missing(this field was called `appid` in previous versions in json) + $appInfo['id'] = $appInfo['id'] ?? $appId; + # during manual install JSON can have all values at root level + foreach (['docker-install', 'scopes', 'system'] as $key) { + if (isset($appInfo[$key])) { + $appInfo['external-app'][$key] = $appInfo[$key]; + unset($appInfo[$key]); + } + } + # TO-DO: remove this in AppAPI 2.4.0 + if (isset($appInfo['system_app'])) { + $appInfo['external-app']['system'] = $appInfo['system_app']; + unset($appInfo['system_app']); + } + } else { + if ($infoXml !== null) { + $xmlAppInfo = simplexml_load_string(file_get_contents($infoXml)); + if ($xmlAppInfo === false) { + return ['error' => sprintf('Failed to load info.xml from %s', $infoXml)]; + } + } else { + $xmlAppInfo = $this->getLatestExAppInfoFromAppstore($appId); + } + $appInfo = json_decode(json_encode((array)$xmlAppInfo), true); + if (isset($appInfo['external-app']['scopes']['value'])) { + $appInfo['external-app']['scopes'] = $appInfo['external-app']['scopes']['value']; + } + # TO-DO: remove this in AppAPI 2.3.0 + if (isset($appInfo['external-app']['scopes']['required']['value'])) { + $appInfo['external-app']['scopes'] = $appInfo['external-app']['scopes']['required']['value']; + } + } + return $appInfo; + } + + public function setAppDeployProgress(ExApp $exApp, int $progress, string $error = ''): void { + if ($progress < 0 || $progress > 100) { + throw new \InvalidArgumentException('Invalid ExApp deploy status progress value'); + } + $status = $exApp->getStatus(); + if ($progress !== 0 && isset($status['deploy']) && $status['deploy'] === 100) { + return; + } + if ($error !== '') { + $this->logger->error(sprintf('ExApp %s deploying failed. Error: %s', $exApp->getAppid(), $error)); + $status['error'] = $error; + } else { + if ($progress === 0) { + $status['action'] = 'deploy'; + $status['deploy_start_time'] = time(); + unset($status['error']); + } + $status['deploy'] = $progress; + } + unset($status['active']); # TO-DO: Remove in AppAPI 2.4.0 + if ($progress === 100) { + $status['action'] = ''; + } + $exApp->setStatus($status); + $exApp->setLastCheckTime(time()); + $this->updateExApp($exApp); + } + + public function waitInitStepFinish(string $appId): string { + do { + $exApp = $this->getExApp($appId); + $status = $exApp->getStatus(); + if (isset($status['error'])) { + return sprintf('ExApp %s initialization step failed. Error: %s', $appId, $status['error']); + } + usleep(100000); // 0.1s + } while ($status['init'] !== 100); + return ""; + } + + public function setStatusError(ExApp $exApp, string $error): void { + $status = $exApp->getStatus(); + $status['error'] = $error; + $exApp->setStatus($status); + $this->updateExApp($exApp, ['status']); + } } diff --git a/src/components/Apps/AppDetails.vue b/src/components/Apps/AppDetails.vue index fe7b65bf..dc6f3900 100644 --- a/src/components/Apps/AppDetails.vue +++ b/src/components/Apps/AppDetails.vue @@ -19,7 +19,7 @@ class="enable" type="button" :value="disableButtonText" - :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isInitializing" + :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" @click="disable(app.id)"> {{ disableButtonText }} @@ -91,7 +91,7 @@ :title="enableButtonTooltip" :aria-label="enableButtonTooltip" type="primary" - :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing" + :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" @click.stop="enable(app.id)"> {{ enableButtonText }} diff --git a/src/components/Apps/DaemonDetails.vue b/src/components/Apps/DaemonDetails.vue index fa95e112..06ca1095 100644 --- a/src/components/Apps/DaemonDetails.vue +++ b/src/components/Apps/DaemonDetails.vue @@ -7,11 +7,7 @@

{{ t('app_api', 'GPUs support') }}: {{ daemon.deploy_config?.gpu || 'false' }}

- {{ t('app_api', 'External App URL: ') }} - {{ app.exAppUrl }} -
-
- {{ t('app_api', 'System: ') }} + {{ t('app_api', 'System app: ') }} {{ app.systemApp }}
diff --git a/src/constants/AppsConstants.js b/src/constants/AppsConstants.js index 5441e987..172e1486 100644 --- a/src/constants/AppsConstants.js +++ b/src/constants/AppsConstants.js @@ -2,6 +2,7 @@ import { translate as t } from '@nextcloud/l10n' /** Enum of verification constants, according to Apps */ export const APPS_SECTION_ENUM = Object.freeze({ + installed: t('settings', 'Your apps'), enabled: t('app_api', 'Active apps'), disabled: t('app_api', 'Disabled apps'), updates: t('app_api', 'Updates'), diff --git a/src/mixins/AppManagement.js b/src/mixins/AppManagement.js index 437d6884..f087184b 100644 --- a/src/mixins/AppManagement.js +++ b/src/mixins/AppManagement.js @@ -10,7 +10,10 @@ export default { return this.app && this.$store.getters.loading(this.app.id) }, isInitializing() { - return this.app && Object.hasOwn(this.app?.status, 'progress') && this.app.status.progress < 100 + return this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init' + }, + isDeploying() { + return this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy' }, isManualInstall() { return this.app?.daemon?.accepts_deploy_id === 'manual-install' @@ -22,8 +25,11 @@ export default { return '' }, enableButtonText() { - if (this.app && Object.hasOwn(this.app?.status, 'progress')) { - return t('app_api', '{progress}% Initializing', { progress: this.app.status?.progress }) + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { + return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { + return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) } if (this.app.needsDownload) { return t('app_api', 'Deploy and Enable') @@ -31,8 +37,11 @@ export default { return t('app_api', 'Enable') }, disableButtonText() { - if (this.app && Object.hasOwn(this.app?.status, 'progress')) { - return t('app_api', '{progress}% Initializing', { progress: this.app.status?.progress }) + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'deploy') { + return t('app_api', '{progress}% Deploying', { progress: this.app.status?.deploy }) + } + if (this.app && Object.hasOwn(this.app?.status, 'action') && this.app.status.action === 'init') { + return t('app_api', '{progress}% Initializing', { progress: this.app.status?.init }) } return t('app_api', 'Disable') }, @@ -69,26 +78,14 @@ export default { }, }, - data() { - return { - groupCheckedAppsData: false, - } - }, - - mounted() { - if (this.app && this.app.groups && this.app.groups.length > 0) { - this.groupCheckedAppsData = true - } - }, - methods: { forceEnable(appId) { - this.$store.dispatch('forceEnableApp', { appId, groups: [] }) + this.$store.dispatch('forceEnableApp', { appId }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, enable(appId) { - this.$store.dispatch('enableApp', { appId, groups: [] }) + this.$store.dispatch('enableApp', { appId }) .then((response) => { rebuildNavigation() }) .catch((error) => { showError(error) }) }, diff --git a/src/store/apps.js b/src/store/apps.js index 9d930b4f..80f6ec44 100644 --- a/src/store/apps.js +++ b/src/store/apps.js @@ -1,6 +1,6 @@ import api from './api.js' import Vue from 'vue' -import { generateUrl } from '@nextcloud/router' +import { generateUrl, generateOcsUrl } from '@nextcloud/router' import { showError, showInfo } from '@nextcloud/dialogs' const state = { @@ -12,6 +12,7 @@ const state = { statusUpdater: null, gettingCategoriesPromise: null, daemonAccessible: false, + defaultDaemon: null, } const mutations = { @@ -57,34 +58,22 @@ const mutations = { }) }, - clearError(state, { appId, error }) { + enableApp(state, { appId }) { const app = state.apps.find(app => app.id === appId) - app.error = null - }, - - enableApp(state, { appId, groups, daemon, systemApp, exAppUrl, status, scopes }) { - const app = state.apps.find(app => app.id === appId) - if (daemon) { + if (!app.installed) { app.installed = true app.needsDownload = false - app.daemon = daemon - } - if (systemApp) { - app.systemApp = systemApp - } - if (exAppUrl) { - app.exAppUrl = exAppUrl - } - if (status) { + app.systemApp = false + app.daemon = state.defaultDaemon app.status = { - progress: 0, + type: 'install', + action: 'deploy', + init: 0, + deploy: 0, } - } - if (scopes) { - app.scopes = scopes + app.scopes = null } app.active = true - app.groups = groups app.canUnInstall = false app.removable = true app.error = null @@ -93,7 +82,6 @@ const mutations = { disableApp(state, appId) { const app = state.apps.find(app => app.id === appId) app.active = false - app.groups = [] if (app.removable) { app.canUnInstall = true } @@ -101,7 +89,6 @@ const mutations = { uninstallApp(state, appId) { state.apps.find(app => app.id === appId).active = false - state.apps.find(app => app.id === appId).groups = [] state.apps.find(app => app.id === appId).needsDownload = true state.apps.find(app => app.id === appId).installed = false state.apps.find(app => app.id === appId).canUnInstall = false @@ -115,58 +102,63 @@ const mutations = { state.apps.find(app => app.id === appId).update = null }, - updateApp(state, { appId, systemApp, exAppUrl, status, scopes }) { + updateApp(state, { appId }) { const app = state.apps.find(app => app.id === appId) const version = app.update app.update = null app.version = version - app.systemApp = systemApp - app.exAppUrl = exAppUrl - app.status = status - app.scopes = scopes + app.status = { + type: 'update', + action: 'deploy', + init: 0, + deploy: 0, + } + app.scopes = null app.error = null state.updateCount-- }, - resetApps(state) { - state.apps = [] - }, - - reset(state) { - state.apps = [] - state.categories = [] - state.updateCount = 0 - }, - startLoading(state, id) { - if (Array.isArray(id)) { - id.forEach((_id) => { - Vue.set(state.loading, _id, true) // eslint-disable-line - }) - } else { - Vue.set(state.loading, id, true) // eslint-disable-line - } + Vue.set(state.loading, id, true) // eslint-disable-line }, stopLoading(state, id) { - if (Array.isArray(id)) { - id.forEach((_id) => { - Vue.set(state.loading, _id, false) // eslint-disable-line - }) - } else { - Vue.set(state.loading, id, false) // eslint-disable-line - } + Vue.set(state.loading, id, false) // eslint-disable-line }, setDaemonAccessible(state, value) { state.daemonAccessible = value }, + setDefaultDaemon(state, value) { + Vue.set(state, 'defaultDaemon', value) // eslint-disable-line + }, + setAppStatus(state, { appId, status }) { - state.apps.find(app => app.id === appId).status = status - if (!Object.hasOwn(status, 'progress') || status?.progress === 100) { - state.apps.find(app => app.id === appId).active = true - state.apps.find(app => app.id === appId).canUnInstall = false + const app = state.apps.find(app => app.id === appId) + if (status.type === 'install' && status.deploy === 100 && status.action === '') { + console.debug('catching intermediate state deploying -> initializing') + // catching moment when app is deployed but initialization status not started yet + status.action = 'init' + } + if (status.error !== '') { + app.error = status.error + } + if (status.deploy === 100 && status.init === 100) { + app.active = true + app.canUnInstall = false + app.removable = true + } + app.status = status + }, + + setExAppInfo(state, { appId, exAppInfo }) { + const app = state.apps.find(app => app.id === appId) + if (exAppInfo.scopes) { + app.scopes = exAppInfo.scopes + } + if (exAppInfo.system) { + app.systemApp = exAppInfo.system } }, @@ -204,36 +196,25 @@ const getters = { getStatusUpdater(state) { return state.statusUpdater }, + getInitializingOrDeployingApps(state) { + return state.apps.filter(app => Object.hasOwn(app.status, 'action') + && (app.status.action === 'deploy' || app.status.action === 'init') + && app.status.type !== '') + }, } const actions = { - enableApp(context, { appId, groups }) { - let apps - if (Array.isArray(appId)) { - apps = appId - } else { - apps = [appId] - } + enableApp(context, { appId }) { return api.requireAdmin().then((response) => { - context.commit('startLoading', apps) + context.commit('startLoading', appId) context.commit('startLoading', 'install') - return api.post(generateUrl('/apps/app_api/apps/enable'), { appIds: apps, groups }) + return api.post(generateUrl(`/apps/app_api/apps/enable/${appId}`)) .then((response) => { - context.commit('stopLoading', apps) + context.commit('stopLoading', appId) context.commit('stopLoading', 'install') - apps.forEach(_appId => { - context.commit('enableApp', { - appId: _appId, - groups, - daemon: response.data.data?.daemon_config, - systemApp: response.data.data?.systemApp, - exAppUrl: response.data.data?.exAppUrl, - status: response.data.data?.status, - scopes: response.data.data?.scopes, - }) - }) + context.commit('enableApp', { appId }) context.dispatch('updateAppsStatus') @@ -257,73 +238,61 @@ const actions = { } }) .catch(() => { - if (!Array.isArray(appId)) { - context.commit('setError', { - appId: apps, - error: t('app_api', 'Error: This app cannot be enabled because it makes the server unstable'), - }) - } + context.commit('setError', { + appId: [appId], + error: t('app_api', 'Error: This app cannot be enabled because it makes the server unstable'), + }) }) }) .catch((error) => { - context.commit('stopLoading', apps) + context.commit('stopLoading', appId) context.commit('stopLoading', 'install') context.commit('setError', { - appId: apps, + appId: [appId], error: error.response.data.data.message, }) context.commit('APPS_API_FAILURE', { appId, error }) }) }).catch((error) => context.commit('API_FAILURE', { appId, error })) }, - forceEnableApp(context, { appId, groups }) { - let apps - if (Array.isArray(appId)) { - apps = appId - } else { - apps = [appId] - } + + forceEnableApp(context, { appId }) { return api.requireAdmin().then(() => { - context.commit('startLoading', apps) + context.commit('startLoading', appId) context.commit('startLoading', 'install') return api.post(generateUrl('/apps/app_api/apps/force'), { appId }) .then((response) => { location.reload() }) .catch((error) => { - context.commit('stopLoading', apps) + context.commit('stopLoading', appId) context.commit('stopLoading', 'install') context.commit('setError', { - appId: apps, + appId: [appId], error: error.response.data.data.message, }) context.commit('APPS_API_FAILURE', { appId, error }) }) }).catch((error) => context.commit('API_FAILURE', { appId, error })) }, + disableApp(context, { appId }) { - let apps - if (Array.isArray(appId)) { - apps = appId - } else { - apps = [appId] - } return api.requireAdmin().then((response) => { - context.commit('startLoading', apps) - return api.post(generateUrl('apps/app_api/apps/disable'), { appIds: apps }) + context.commit('startLoading', appId) + return api.get(generateUrl(`apps/app_api/apps/disable/${appId}`)) .then((response) => { - context.commit('stopLoading', apps) - apps.forEach(_appId => { - context.commit('disableApp', _appId) - }) + context.commit('stopLoading', appId) + context.commit('disableApp', appId) return true }) .catch((error) => { - context.commit('stopLoading', apps) + context.commit('disableApp', appId) + context.commit('stopLoading', appId) context.commit('APPS_API_FAILURE', { appId, error }) }) }).catch((error) => context.commit('API_FAILURE', { appId, error })) }, + uninstallApp(context, { appId }) { return api.requireAdmin().then((response) => { context.commit('startLoading', appId) @@ -348,13 +317,7 @@ const actions = { .then((response) => { context.commit('stopLoading', 'install') context.commit('stopLoading', appId) - context.commit('updateApp', { - appId, - systemApp: response.data.data?.systemApp, - exAppUrl: response.data.data?.exAppUrl, - status: response.data.data?.status, - scopes: response.data.data?.scopes, - }) + context.commit('updateApp', { appId }) context.dispatch('updateAppsStatus') return true }) @@ -398,34 +361,51 @@ const actions = { return context.state.gettingCategoriesPromise }, + getExAppInfo(context, { appId }) { + return api.get(generateOcsUrl(`/apps/app_api/api/v1/ex-app/info/${appId}`)).then((response) => { + context.commit('setExAppInfo', { appId, exAppInfo: response.data?.ocs.data }) + }) + }, + getAppStatus(context, { appId }) { return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`)) .then((response) => { context.commit('setAppStatus', { appId, status: response.data }) - if (!Object.hasOwn(response.data, 'progress') || response.data?.progress === 100) { - const initializingApps = context.getters.getAllApps.filter(app => Object.hasOwn(app.status, 'progress')) - if (initializingApps.length === 0) { - clearInterval(context.getters.getStatusUpdater) - context.commit('setIntervalUpdater', null) - } + const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps after setAppStatus', initializingOrDeployingApps) + if (initializingOrDeployingApps.length === 0) { + console.debug('clearing interval') + clearInterval(context.getters.getStatusUpdater) + context.commit('setIntervalUpdater', null) } - if (Object.hasOwn(state, 'error') && state?.error !== '') { - context.commit('setError', { - appId: [appId], - error: response.data?.error, - }) + if (Object.hasOwn(response.data, 'error') + && response.data.error !== '' + && initializingOrDeployingApps.length === 1) { + clearInterval(context.getters.getStatusUpdater) + context.commit('setIntervalUpdater', null) } }) - .catch((error) => context.commit('API_FAILURE', error)) + .catch((error) => { + context.commit('API_FAILURE', error) + context.commit('unregisterApp', { appId }) + context.dispatch('updateAppsStatus') + }) }, updateAppsStatus(context) { + clearInterval(context.getters.getStatusUpdater) // clear previous interval if exists context.commit('setIntervalUpdater', setInterval(() => { - const initializingApps = context.getters.getAllApps.filter(app => Object.hasOwn(app.status, 'progress')) - Array.from(initializingApps).forEach(app => { + const initializingOrDeployingApps = context.getters.getInitializingOrDeployingApps + console.debug('initializingOrDeployingApps', initializingOrDeployingApps) + Array.from(initializingOrDeployingApps).forEach(app => { context.dispatch('getAppStatus', { appId: app.id }) + if ((app.status.deploy === 100 && app.status.init === 0) || app.status.type === 'update') { + console.debug('getExAppInfo', app.id) + // get ExApp info once app is deployed or during update + context.dispatch('getExAppInfo', { appId: app.id }) + } }) - }, 5000)) + }, 2000)) }, } diff --git a/src/views/Apps.vue b/src/views/Apps.vue index c7611ee6..1133896b 100644 --- a/src/views/Apps.vue +++ b/src/views/Apps.vue @@ -304,6 +304,7 @@ export default { this.$store.dispatch('getAllApps') this.$store.commit('setUpdateCount', this.state.updateCount) this.$store.commit('setDaemonAccessible', this.state.daemon_config_accessible) + this.$store.commit('setDefaultDaemon', this.state.default_daemon_config) this.$store.dispatch('updateAppsStatus') }, diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9d0182c7..15c9a36f 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -10,16 +10,6 @@ DavPlugin - - - id]]> - - - - - id]]> - - categoryFetcher]]>