Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: advanced deploy options #497

Merged
merged 1 commit into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
andrey18106 marked this conversation as resolved.
Show resolved Hide resolved
$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
Loading