Skip to content

Commit

Permalink
feat: Advanced deploy options
Browse files Browse the repository at this point in the history
Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
  • Loading branch information
andrey18106 committed Jan 20, 2025
1 parent 4f526b6 commit bd8b37f
Show file tree
Hide file tree
Showing 13 changed files with 637 additions and 24 deletions.
160 changes: 160 additions & 0 deletions .github/workflows/tests-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
]]></description>
<version>32.0.0-dev.1</version>
<version>32.0.0-dev.2</version>
<licence>agpl</licence>
<author mail="andrey18106x@gmail.com" homepage="/~https://github.com/andrey18106">Andrey Borysenko</author>
<author mail="bigcat88@icloud.com" homepage="/~https://github.com/bigcat88">Alexander Piskun</author>
Expand Down
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => ''],
Expand Down
35 changes: 33 additions & 2 deletions lib/Command/ExApp/Register.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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']);
Expand All @@ -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);
Expand Down
24 changes: 15 additions & 9 deletions lib/Command/ExApp/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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']);
Expand Down
55 changes: 53 additions & 2 deletions lib/Controller/ExAppsPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down
Loading

0 comments on commit bd8b37f

Please sign in to comment.