diff --git a/.github/workflows/tests-deploy.yml b/.github/workflows/tests-deploy.yml index 636eec79..15b687e1 100644 --- a/.github/workflows/tests-deploy.yml +++ b/.github/workflows/tests-deploy.yml @@ -709,6 +709,166 @@ jobs: path: data/nextcloud.log if-no-files-found: warn + nc-host-app-docker-redis-deploy-options: + runs-on: ubuntu-22.04 + name: NC In Host(Redis) Deploy options • master • 🐘8.3 + + services: + postgres: + image: ghcr.io/nextcloud/continuous-integration-postgres-14:latest + ports: + - 4444:5432/tcp + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --name redis + ports: + - 6379:6379 + + steps: + - name: Set app env + run: echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Checkout server + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + with: + submodules: true + repository: nextcloud/server + ref: master + + - name: Checkout AppAPI + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + with: + path: apps/${{ env.APP_NAME }} + + - name: Set up php 8.3 + uses: shivammathur/setup-php@4bd44f22a98a19e0950cbad5f31095157cc9621b # v2 + with: + php-version: 8.3 + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql, redis + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check composer file existence + id: check_composer + uses: andstor/file-existence-action@20b4d2e596410855db8f9ca21e96fbe18e12930b # v2 + with: + files: apps/${{ env.APP_NAME }}/composer.json + + - name: Set up dependencies + if: steps.check_composer.outputs.files_exists == 'true' + working-directory: apps/${{ env.APP_NAME }} + run: composer i + + - name: Set up Nextcloud + env: + DB_PORT: 4444 + REDIS_HOST: localhost + REDIS_PORT: 6379 + run: | + mkdir data + ./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 \ + --database-port=$DB_PORT --database-user=root --database-pass=rootpassword \ + --admin-user admin --admin-pass admin + ./occ config:system:set loglevel --value=0 --type=integer + ./occ config:system:set debug --value=true --type=boolean + + ./occ config:system:set memcache.local --value "\\OC\\Memcache\\Redis" + ./occ config:system:set memcache.distributed --value "\\OC\\Memcache\\Redis" + ./occ config:system:set memcache.locking --value "\\OC\\Memcache\\Redis" + ./occ config:system:set redis host --value ${{ env.REDIS_HOST }} + ./occ config:system:set redis port --value ${{ env.REDIS_PORT }} + + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Test deploy + 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:daemon:list + mkdir -p ./test_mount + TEST_MOUNT_ABS_PATH=$(pwd)/test_mount + ./occ app_api:app:register app-skeleton-python docker_local_sock \ + --info-xml https://raw.githubusercontent.com/nextcloud/app-skeleton-python/main/appinfo/info.xml \ + --env='TEST_ENV_2=2' \ + --mount "$TEST_MOUNT_ABS_PATH:/test_mount:rw" + ./occ app_api:app:enable app-skeleton-python + ./occ app_api:app:disable app-skeleton-python + + - name: Check logs + run: | + grep -q 'Hello from app-skeleton-python :)' data/nextcloud.log || error + grep -q 'Bye bye from app-skeleton-python :(' data/nextcloud.log || error + + - name: Check docker inspect TEST_ENV_1 + run: | + docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q 'TEST_ENV_1=0' || error + + - name: Check docker inspect TEST_ENV_2 + run: | + docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q 'TEST_ENV_2=2' || error + + - name: Check docker inspect TEST_ENV_3 + run: | + docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q 'TEST_ENV_3=' && error || true + + - name: Check docker inspect TEST_MOUNT + run: | + docker inspect --format '{{ json .Mounts }}' nc_app_app-skeleton-python | grep -q "Source\":\"$(printf '%s' "$TEST_MOUNT_ABS_PATH" | sed 's/[][\.*^$]/\\&/g')" || { echo "Error: TEST_MOUNT_ABS_PATH not found"; exit 1; } + + - name: Save container info & logs + if: always() + run: | + docker inspect nc_app_app-skeleton-python | json_pp > container.json + docker logs nc_app_app-skeleton-python > container.log 2>&1 + + - name: Unregister Skeleton & Daemon + run: | + ./occ app_api:app:unregister app-skeleton-python + ./occ app_api:daemon:unregister docker_local_sock + + - name: Test OCC commands(docker) + run: python3 apps/${{ env.APP_NAME }}/tests/test_occ_commands_docker.py + + - name: Check redis keys + run: | + docker exec redis redis-cli keys '*app_api*' || error + + - name: Upload Container info + if: always() + uses: actions/upload-artifact@v4 + with: + name: nc_host_app_docker_redis_deploy_options_master_8.3_container.json + path: container.json + if-no-files-found: warn + + - name: Upload Container logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: nc_host_app_docker_redis_deploy_options_master_8.3_container.log + path: container.log + if-no-files-found: warn + + - name: Upload NC logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: nc_host_app_docker_redis_deploy_options_master_8.3_nextcloud.log + path: data/nextcloud.log + if-no-files-found: warn + nc-host-network-host: runs-on: ubuntu-22.04 name: NC In Host(network=host) • master • 🐘8.3 diff --git a/appinfo/info.xml b/appinfo/info.xml index 7eb020a8..756b4a81 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -38,7 +38,7 @@ to join us in shaping a more versatile, stable, and secure app landscape. *Your insights, suggestions, and contributions are invaluable to us.* ]]> - 32.0.0-dev.1 + 32.0.0-dev.2 agpl Andrey Borysenko Alexander Piskun diff --git a/appinfo/routes.php b/appinfo/routes.php index 11789d74..0d2c7493 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -38,6 +38,7 @@ ['name' => 'ExAppsPage#enableApp', 'url' => '/apps/enable/{appId}', 'verb' => 'POST' , 'root' => ''], ['name' => 'ExAppsPage#getAppStatus', 'url' => '/apps/status/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#getAppLogs', 'url' => '/apps/logs/{appId}', 'verb' => 'GET' , 'root' => ''], + ['name' => 'ExAppsPage#getAppDeployOptions', 'url' => '/apps/deploy-options/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#disableApp', 'url' => '/apps/disable/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#updateApp', 'url' => '/apps/update/{appId}', 'verb' => 'GET' , 'root' => ''], ['name' => 'ExAppsPage#uninstallApp', 'url' => '/apps/uninstall/{appId}', 'verb' => 'GET' , 'root' => ''], diff --git a/lib/Command/ExApp/Register.php b/lib/Command/ExApp/Register.php index 0a422396..f3d2d9fa 100644 --- a/lib/Command/ExApp/Register.php +++ b/lib/Command/ExApp/Register.php @@ -55,6 +55,10 @@ protected function configure(): void { $this->addOption('wait-finish', null, InputOption::VALUE_NONE, 'Wait until finish'); $this->addOption('silent', null, InputOption::VALUE_NONE, 'Do not print to console'); $this->addOption('test-deploy-mode', null, InputOption::VALUE_NONE, 'Test deploy mode with additional status checks and slightly different logic'); + + // Advanced deploy options + $this->addOption('env', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Optional deploy options (ENV_NAME=ENV_VALUE), passed to ExApp container as environment variables'); + $this->addOption('mount', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Optional mount options (SRC_PATH:DST_PATH or SRC_PATH:DST_PATH:ro|rw), passed to ExApp container as volume mounts only if the app declares those variables in its info.xml'); } protected function execute(InputInterface $input, OutputInterface $output): int { @@ -73,8 +77,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->exAppService->unregisterExApp($appId); } + $deployOptions = []; + $envs = $input->getOption('env') ?? []; + // Parse array of deploy options strings (ENV_NAME=ENV_VALUE) to array key => value + $envs = array_reduce($envs, function ($carry, $item) { + $parts = explode('=', $item, 2); + if (count($parts) === 2) { + $carry[$parts[0]] = $parts[1]; + } + return $carry; + }, []); + $deployOptions['environment_variables'] = $envs; + + $mounts = $input->getOption('mount') ?? []; + // Parse array of mount options strings (HOST_PATH:CONTAINER_PATH:ro|rw) + // to array of arrays ['source' => HOST_PATH, 'target' => CONTAINER_PATH, 'mode' => ro|rw] + $mounts = array_reduce($mounts, function ($carry, $item) { + $parts = explode(':', $item, 3); + if (count($parts) === 3) { + $carry[] = ['source' => $parts[0], 'target' => $parts[1], 'mode' => $parts[2]]; + } elseif (count($parts) === 2) { + $carry[] = ['source' => $parts[0], 'target' => $parts[1], 'mode' => 'rw']; + } + return $carry; + }, ); + $deployOptions['mounts'] = $mounts; + $appInfo = $this->exAppService->getAppInfo( - $appId, $input->getOption('info-xml'), $input->getOption('json-info') + $appId, $input->getOption('info-xml'), $input->getOption('json-info'), + $deployOptions ); if (isset($appInfo['error'])) { $this->logger->error($appInfo['error']); @@ -86,7 +117,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $appId = $appInfo['id']; # value from $appInfo should have higher priority $daemonConfigName = $input->getArgument('daemon-config-name'); - if (!isset($daemonConfigName)) { + if (!isset($daemonConfigName) || $daemonConfigName === '') { $daemonConfigName = $this->config->getAppValue(Application::APP_ID, 'default_daemon_config'); } $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($daemonConfigName); diff --git a/lib/Command/ExApp/Update.php b/lib/Command/ExApp/Update.php index 50b52ec3..0e862537 100644 --- a/lib/Command/ExApp/Update.php +++ b/lib/Command/ExApp/Update.php @@ -16,6 +16,7 @@ use OCA\AppAPI\Service\AppAPIService; use OCA\AppAPI\Service\DaemonConfigService; +use OCA\AppAPI\Service\ExAppDeployOptionsService; use OCA\AppAPI\Service\ExAppService; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; @@ -27,14 +28,15 @@ class Update extends Command { public function __construct( - private readonly AppAPIService $service, - private readonly ExAppService $exAppService, - private readonly DaemonConfigService $daemonConfigService, - private readonly DockerActions $dockerActions, - private readonly ManualActions $manualActions, - private readonly LoggerInterface $logger, - private readonly ExAppArchiveFetcher $exAppArchiveFetcher, - private readonly ExAppFetcher $exAppFetcher, + private readonly AppAPIService $service, + private readonly ExAppService $exAppService, + private readonly DaemonConfigService $daemonConfigService, + private readonly DockerActions $dockerActions, + private readonly ManualActions $manualActions, + private readonly LoggerInterface $logger, + private readonly ExAppArchiveFetcher $exAppArchiveFetcher, + private readonly ExAppFetcher $exAppFetcher, + private readonly ExAppDeployOptionsService $exAppDeployOptionsService, ) { parent::__construct(); } @@ -90,8 +92,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function updateExApp(InputInterface $input, OutputInterface $output, string $appId): int { $outputConsole = !$input->getOption('silent'); + $deployOptions = $this->exAppDeployOptionsService->formatDeployOptions( + $this->exAppDeployOptionsService->getDeployOptions() + ); $appInfo = $this->exAppService->getAppInfo( - $appId, $input->getOption('info-xml'), $input->getOption('json-info') + $appId, $input->getOption('info-xml'), $input->getOption('json-info'), + $deployOptions ); if (isset($appInfo['error'])) { $this->logger->error($appInfo['error']); diff --git a/lib/Controller/ExAppsPageController.php b/lib/Controller/ExAppsPageController.php index c2c99d20..9a63ea36 100644 --- a/lib/Controller/ExAppsPageController.php +++ b/lib/Controller/ExAppsPageController.php @@ -21,6 +21,7 @@ use OCA\AppAPI\Fetcher\ExAppFetcher; use OCA\AppAPI\Service\AppAPIService; use OCA\AppAPI\Service\DaemonConfigService; +use OCA\AppAPI\Service\ExAppDeployOptionsService; use OCA\AppAPI\Service\ExAppService; use OCP\App\IAppManager; use OCP\AppFramework\Controller; @@ -53,6 +54,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly IAppManager $appManager, private readonly ExAppService $exAppService, + private readonly ExAppDeployOptionsService $exAppDeployOptionsService, ) { parent::__construct(Application::APP_ID, $request); } @@ -312,12 +314,29 @@ private function buildLocalAppsList(array $apps, array $exApps): array { } #[PasswordConfirmationRequired] - public function enableApp(string $appId): JSONResponse { + public function enableApp(string $appId, array $deployOptions = []): JSONResponse { $updateRequired = false; $exApp = $this->exAppService->getExApp($appId); + + $envOptions = isset($deployOptions['environment_variables']) + ? array_keys($deployOptions['environment_variables']) : []; + $envOptionsString = ''; + foreach ($envOptions as $envOption) { + $envOptionsString .= sprintf(' --env %s=%s', $envOption, $deployOptions['environment_variables'][$envOption]); + } + $envOptionsString = trim($envOptionsString); + + $mountOptions = $deployOptions['mounts'] ?? []; + $mountOptionsString = ''; + foreach ($mountOptions as $mountOption) { + $readonlyModifier = $mountOption['readonly'] ? 'ro' : 'rw'; + $mountOptionsString .= sprintf(' --mount %s:%s:%s', $mountOption['hostPath'], $mountOption['containerPath'], $readonlyModifier); + } + $mountOptionsString = trim($mountOptionsString); + // If ExApp is not registered - then it's a "Deploy and Enable" action. if (!$exApp) { - if (!$this->service->runOccCommand(sprintf("app_api:app:register --silent %s", $appId))) { + if (!$this->service->runOccCommand(sprintf("app_api:app:register --silent %s %s %s", $appId, $envOptionsString, $mountOptionsString))) { return new JSONResponse(['data' => ['message' => $this->l10n->t('Error starting install of ExApp')]], Http::STATUS_INTERNAL_SERVER_ERROR); } $elapsedTime = 0; @@ -481,6 +500,38 @@ public function getAppLogs(string $appId, string $tail = 'all'): DataDownloadRes } } + public function getAppDeployOptions(string $appId) { + $exApp = $this->exAppService->getExApp($appId); + if (is_null($exApp)) { + return new JSONResponse(['error' => $this->l10n->t('ExApp not found, failed to get deploy options')], Http::STATUS_NOT_FOUND); + } + + $deployOptions = $this->exAppDeployOptionsService->formatDeployOptions( + $this->exAppDeployOptionsService->getDeployOptions($appId) + ); + + $envs = []; + if (isset($deployOptions['environment_variables'])) { + $envs = $deployOptions['environment_variables']; + } + + $mounts = []; + if (isset($deployOptions['mounts'])) { + foreach ($deployOptions['mounts'] as $mount) { + $mounts[] = [ + 'hostPath' => $mount['source'], + 'containerPath' => $mount['target'], + 'readonly' => $mount['mode'] === 'ro' + ]; + } + } + + return new JSONResponse([ + 'environment_variables' => $envs, + 'mounts' => $mounts, + ]); + } + /** * Using default methods to fetch App Store categories as they are the same for ExApps * diff --git a/lib/Db/ExAppDeployOption.php b/lib/Db/ExAppDeployOption.php new file mode 100644 index 00000000..54bbc627 --- /dev/null +++ b/lib/Db/ExAppDeployOption.php @@ -0,0 +1,64 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('type', 'string'); + $this->addType('value', 'json'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['type'])) { + $this->setType($params['type']); + } + if (isset($params['value'])) { + $this->setValue($params['value']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'type' => $this->getType(), + 'value' => $this->getValue(), + ]; + } +} diff --git a/lib/Db/ExAppDeployOptionsMapper.php b/lib/Db/ExAppDeployOptionsMapper.php new file mode 100644 index 00000000..44d6759f --- /dev/null +++ b/lib/Db/ExAppDeployOptionsMapper.php @@ -0,0 +1,47 @@ + + */ +class ExAppDeployOptionsMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_deploy_options'); + } + + /** + * @throws Exception + */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('exs.*') + ->from($this->tableName, 'exs') + ->executeQuery(); + return $result->fetchAll(); + } + + /** + * @throws Exception + */ + public function removeAllByAppId(string $appId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)) + ); + return $qb->executeStatement(); + } +} diff --git a/lib/DeployActions/DockerActions.php b/lib/DeployActions/DockerActions.php index f2a34d9b..27754b93 100644 --- a/lib/DeployActions/DockerActions.php +++ b/lib/DeployActions/DockerActions.php @@ -17,6 +17,7 @@ use OCA\AppAPI\Db\ExApp; use OCA\AppAPI\Service\AppAPICommonService; +use OCA\AppAPI\Service\ExAppDeployOptionsService; use OCA\AppAPI\Service\ExAppService; use OCP\App\IAppManager; @@ -38,15 +39,16 @@ class DockerActions implements IDeployActions { 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 IURLGenerator $urlGenerator, - private readonly AppAPICommonService $service, - private readonly ExAppService $exAppService, - private readonly ITempManager $tempManager, - private readonly ICrypto $crypto, + private readonly LoggerInterface $logger, + private readonly IConfig $config, + private readonly ICertificateManager $certificateManager, + private readonly IAppManager $appManager, + private readonly IURLGenerator $urlGenerator, + private readonly AppAPICommonService $service, + private readonly ExAppService $exAppService, + private readonly ITempManager $tempManager, + private readonly ICrypto $crypto, + private readonly ExAppDeployOptionsService $exAppDeployOptionsService, ) { } @@ -95,6 +97,10 @@ public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $par if (isset($result['error'])) { return $result['error']; } + + $this->exAppDeployOptionsService->removeExAppDeployOptions($exApp->getAppid()); + $this->exAppDeployOptionsService->addExAppDeployOptions($exApp->getAppid(), $params['deploy_options']); + $this->exAppService->setAppDeployProgress($exApp, 99); if (!$this->waitTillContainerStart($containerName, $daemonConfig)) { return 'container startup failed'; @@ -350,6 +356,20 @@ public function createContainer(string $dockerUrl, string $imageId, DaemonConfig } } + if (isset($params['mounts'])) { + $containerParams['HostConfig']['Mounts'] = array_merge( + $containerParams['HostConfig']['Mounts'] ?? [], + array_map(function ($mount) { + return [ + 'Source' => $mount['source'], + 'Target' => $mount['target'], + 'Type' => 'bind', // we don't support other types for now + 'ReadOnly' => $mount['mode'] === 'ro', + ]; + }, $params['mounts']) + ); + } + $url = $this->buildApiUrl($dockerUrl, sprintf('containers/create?name=%s', urlencode($this->buildExAppContainerName($params['name'])))); try { $options['json'] = $containerParams; @@ -677,6 +697,7 @@ public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo): a 'port' => $appInfo['port'], 'storage' => $storage, 'secret' => $appInfo['secret'], + 'environment_variables' => $appInfo['external-app']['environment-variables'] ?? [], ], $deployConfig); $containerParams = [ @@ -688,11 +709,16 @@ public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo): a 'computeDevice' => $deployConfig['computeDevice'] ?? null, 'devices' => $devices, 'deviceRequests' => $deviceRequests, + 'mounts' => $appInfo['external-app']['mounts'] ?? [], ]; return [ 'image_params' => $imageParams, 'container_params' => $containerParams, + 'deploy_options' => [ + 'environment_variables' => $appInfo['external-app']['environment-variables'] ?? [], + 'mounts' => $appInfo['external-app']['mounts'] ?? [], + ] ]; } @@ -718,6 +744,12 @@ public function buildDeployEnvs(array $params, array $deployConfig): array { $autoEnvs[] = sprintf('NVIDIA_DRIVER_CAPABILITIES=%s', 'compute,utility'); } } + + // Appending additional deploy options to container envs + foreach (array_keys($params['environment_variables']) as $envKey) { + $autoEnvs[] = sprintf('%s=%s', $envKey, $params['environment_variables'][$envKey]['value'] ?? ''); + } + return $autoEnvs; } diff --git a/lib/Migration/Version032001Date20250115164140.php b/lib/Migration/Version032001Date20250115164140.php new file mode 100644 index 00000000..04cb1cbe --- /dev/null +++ b/lib/Migration/Version032001Date20250115164140.php @@ -0,0 +1,59 @@ +hasTable('ex_deploy_options')) { + $table = $schema->createTable('ex_deploy_options'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'autoincrement' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('type', Types::STRING, [ // environment_variables/mounts/ports + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('value', Types::JSON, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'type'], 'deploy_options__idx'); + } + + + return $schema; + } +} diff --git a/lib/Service/ExAppDeployOptionsService.php b/lib/Service/ExAppDeployOptionsService.php new file mode 100644 index 00000000..bc41e100 --- /dev/null +++ b/lib/Service/ExAppDeployOptionsService.php @@ -0,0 +1,126 @@ +isAvailable()) { + $this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_deploy_options'); + } + } + + public function addExAppDeployOptions(string $appId, array $deployOptions): array { + $added = []; + foreach (array_keys($deployOptions) as $type) { + if ($this->addExAppDeployOption($appId, $type, $deployOptions[$type])) { + $added[$type] = $deployOptions[$type]; + } + } + return $added; + } + + public function addExAppDeployOption( + string $appId, + string $type, + mixed $value, + ): ?ExAppDeployOption { + $deployOptionEntry = $this->getDeployOption($appId, $type); + try { + $newExAppDeployOption = new ExAppDeployOption([ + 'appid' => $appId, + 'type' => $type, + 'value' => $value, + ]); + if ($deployOptionEntry !== null) { + $newExAppDeployOption->setId($deployOptionEntry->getId()); + } + $exAppDeployOption = $this->mapper->insertOrUpdate($newExAppDeployOption); + $this->resetCache(); + } catch (Exception $e) { + $this->logger->error( + sprintf('Failed to register ExApp Deploy option for %s. Error: %s', $appId, $e->getMessage()), ['exception' => $e] + ); + return null; + } + return $exAppDeployOption; + } + + public function getDeployOption(string $appId, string $type): ?ExAppDeployOption { + foreach ($this->getDeployOptions() as $deployOption) { + if (($deployOption->getAppid() === $appId) && ($deployOption->getType() === $type)) { + return $deployOption; + } + } + return null; + } + + /** + * Get list of all registered ExApp Deploy Options + * + * @return ExAppDeployOption[] + */ + public function getDeployOptions(?string $appId = null): array { + try { + $cacheKey = '/ex_deploy_options'; + $records = $this->cache?->get($cacheKey); + if ($records === null) { + $records = $this->mapper->findAll(); + $this->cache?->set($cacheKey, $records); + } + if ($appId !== null) { + $records = array_values(array_filter($records, function ($record) use ($appId) { + return $record['appid'] === $appId; + })); + } + return array_map(function ($record) { + return new ExAppDeployOption($record); + }, $records); + } catch (Exception) { + return []; + } + } + + public function formatDeployOptions(array $deployOptions): array { + $formattedDeployOptions = []; + foreach ($deployOptions as $deployOption) { + $formattedDeployOptions[$deployOption->getType()] = $deployOption->getValue(); + } + return $formattedDeployOptions; + } + + public function removeExAppDeployOptions(string $appId): int { + try { + $result = $this->mapper->removeAllByAppId($appId); + } catch (Exception) { + $result = -1; + } + $this->resetCache(); + return $result; + } + + public function resetCache(): void { + $this->cache?->remove('/ex_deploy_options'); + } +} diff --git a/lib/Service/ExAppService.php b/lib/Service/ExAppService.php index 059008d9..cd8d90db 100644 --- a/lib/Service/ExAppService.php +++ b/lib/Service/ExAppService.php @@ -60,6 +60,7 @@ public function __construct( private readonly SettingsService $settingsService, private readonly ExAppEventsListenerService $eventsListenerService, private readonly ExAppOccService $occService, + private readonly ExAppDeployOptionsService $deployOptionsService, private readonly IConfig $config, ) { if ($cacheFactory->isAvailable()) { @@ -128,6 +129,7 @@ public function unregisterExApp(string $appId): bool { $this->exAppArchiveFetcher->removeExAppFolder($appId); $this->eventsListenerService->unregisterExAppEventListeners($appId); $this->occService->unregisterExAppOccCommands($appId); + $this->deployOptionsService->removeExAppDeployOptions($appId); $this->unregisterExAppWebhooks($appId); $r = $this->exAppMapper->deleteExApp($appId); if ($r !== 1) { @@ -256,9 +258,10 @@ private function resetCaches(): void { $this->settingsService->resetCacheEnabled(); $this->eventsListenerService->resetCacheEnabled(); $this->occService->resetCacheEnabled(); + $this->deployOptionsService->resetCache(); } - public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo): array { + public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo, ?array $deployOptions = null): array { $extractedDir = ''; if ($jsonInfo !== null) { $appInfo = json_decode($jsonInfo, true); @@ -300,6 +303,34 @@ public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo): } }, $appInfo['external-app']['routes']); } + // Advanced deploy options + if (isset($appInfo['external-app']['environment-variables']['variable'])) { + $envVars = []; + foreach ($appInfo['external-app']['environment-variables']['variable'] as $envVar) { + $envVars[$envVar['name']] = [ + 'name' => $envVar['name'], + 'displayName' => $envVar['display-name'] ?? '', + 'description' => $envVar['description'] ?? '', + 'default' => $envVar['default'] ?? '', + 'value' => $envVar['default'] ?? '', + ]; + } + if (isset($deployOptions['environment_variables']) && count(array_keys($deployOptions['environment_variables'])) > 0) { + // override with given deploy options values + foreach ($deployOptions['environment_variables'] as $key => $value) { + if (array_key_exists($key, $envVars)) { + $envVars[$key]['value'] = $value['value'] ?? $value ?? ''; + } + } + } + $envVars = array_filter($envVars, function ($envVar) { + return $envVar['value'] !== ''; + }); + $appInfo['external-app']['environment-variables'] = $envVars; + } + if (isset($deployOptions['mounts'])) { + $appInfo['external-app']['mounts'] = $deployOptions['mounts']; + } if ($extractedDir) { if (file_exists($extractedDir . '/l10n')) { $appInfo['translations_folder'] = $extractedDir . '/l10n'; diff --git a/lib/Service/ExAppsPageService.php b/lib/Service/ExAppsPageService.php index 5d7d9f5a..12d3e7b8 100644 --- a/lib/Service/ExAppsPageService.php +++ b/lib/Service/ExAppsPageService.php @@ -14,6 +14,7 @@ use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; +use OCP\IURLGenerator; use Psr\Log\LoggerInterface; class ExAppsPageService { @@ -25,6 +26,7 @@ public function __construct( private readonly IConfig $config, private readonly IAppManager $appManager, private readonly LoggerInterface $logger, + private readonly IURLGenerator $urlGenerator, ) { } @@ -61,5 +63,8 @@ public function provideAppApiState(IInitialState $initialState): void { if ($defaultDaemonConfig !== null) { $initialState->provideInitialState('defaultDaemonConfig', $defaultDaemonConfig); } + + $deployOptionsDocsUrl = $this->urlGenerator->linkToDocs('admin-deploy-options'); + $initialState->provideInitialState('deployOptionsDocsUrl', $deployOptionsDocsUrl); } }