diff --git a/.github/workflows/tests-deploy-k8s.yml b/.github/workflows/tests-deploy-k8s.yml
index 8c128a3b..dabb688e 100644
--- a/.github/workflows/tests-deploy-k8s.yml
+++ b/.github/workflows/tests-deploy-k8s.yml
@@ -216,11 +216,244 @@ jobs:
path: data/nextcloud.log
if-no-files-found: warn
+ k8s-update-preserves-deploy-options:
+ runs-on: ubuntu-22.04
+ name: Update preserves deploy options (K8s)
+ # Regression test for https://github.com/nextcloud/app_api/issues/808
+ # on the K8s deploy path. Mirrors the Docker job in tests-deploy.yml.
+
+ services:
+ postgres:
+ image: ghcr.io/nextcloud/continuous-integration-postgres-14:latest # zizmor: ignore[unpinned-images]
+ 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
+
+ steps:
+ - name: Set app env
+ run: echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV
+
+ - name: Checkout server
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ submodules: true
+ repository: nextcloud/server
+ ref: master
+
+ - name: Checkout AppAPI
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ path: apps/${{ env.APP_NAME }}
+
+ - name: Set up php
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 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
+ coverage: none
+ ini-file: development
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check composer file existence
+ id: check_composer
+ uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # 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
+ 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 app:enable --force ${{ env.APP_NAME }}
+
+ - name: Install k3s
+ run: |
+ curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik --disable servicelb" sh -
+ sudo chmod 644 /etc/rancher/k3s/k3s.yaml
+ echo "KUBECONFIG=/etc/rancher/k3s/k3s.yaml" >> $GITHUB_ENV
+
+ - name: Wait for k3s and create namespace
+ run: |
+ kubectl wait --for=condition=Ready node --all --timeout=120s
+ kubectl create namespace nextcloud-exapps
+ NODE_IP=$(kubectl get node -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
+ echo "NODE_IP=${NODE_IP}" >> $GITHUB_ENV
+
+ - name: Configure Nextcloud for k3s networking
+ run: |
+ ./occ config:system:set overwrite.cli.url --value "http://${{ env.NODE_IP }}" --type=string
+ ./occ config:system:set trusted_domains 1 --value "${{ env.NODE_IP }}"
+
+ - name: Create K8s service account for HaRP
+ run: |
+ kubectl -n nextcloud-exapps create serviceaccount harp-sa
+ kubectl create clusterrolebinding harp-admin \
+ --clusterrole=cluster-admin \
+ --serviceaccount=nextcloud-exapps:harp-sa
+ K3S_TOKEN=$(kubectl -n nextcloud-exapps create token harp-sa --duration=2h)
+ echo "K3S_TOKEN=${K3S_TOKEN}" >> $GITHUB_ENV
+
+ - name: Pre-pull ExApp image into k3s
+ run: sudo k3s ctr images pull ghcr.io/nextcloud/app-skeleton-python:latest
+
+ - name: Pull HaRP image
+ run: docker pull ghcr.io/nextcloud/nextcloud-appapi-harp:latest
+
+ - name: Start HaRP with K8s backend
+ run: |
+ docker run --net host --name appapi-harp \
+ -e HP_SHARED_KEY="${{ env.HP_SHARED_KEY }}" \
+ -e NC_INSTANCE_URL="http://${{ env.NODE_IP }}" \
+ -e HP_LOG_LEVEL="debug" \
+ -e HP_K8S_ENABLED="true" \
+ -e HP_K8S_API_SERVER="https://127.0.0.1:6443" \
+ -e HP_K8S_BEARER_TOKEN="${{ env.K3S_TOKEN }}" \
+ -e HP_K8S_NAMESPACE="nextcloud-exapps" \
+ -e HP_K8S_VERIFY_SSL="false" \
+ --restart unless-stopped \
+ -d ghcr.io/nextcloud/nextcloud-appapi-harp:latest
+
+ - name: Start nginx proxy
+ run: |
+ docker run --net host --name nextcloud --rm \
+ -v $(pwd)/apps/${{ env.APP_NAME }}/tests/simple-nginx-NOT-FOR-PRODUCTION.conf:/etc/nginx/conf.d/default.conf:ro \
+ -d nginx
+
+ - name: Start Nextcloud
+ run: PHP_CLI_SERVER_WORKERS=2 php -S 0.0.0.0:8080 &
+
+ - name: Wait for HaRP K8s readiness
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf http://${{ env.NODE_IP }}:8780/exapps/app_api/info \
+ -H "harp-shared-key: ${{ env.HP_SHARED_KEY }}" 2>/dev/null | grep -q '"kubernetes"'; then
+ echo "HaRP is ready"
+ exit 0
+ fi
+ sleep 2
+ done
+ docker logs appapi-harp
+ exit 1
+
+ - name: Register K8s daemon and Skeleton v1 with user env vars
+ run: |
+ ./occ app_api:daemon:register \
+ k8s_test "K8s Test" "kubernetes-install" "http" "${{ env.NODE_IP }}:8780" "http://${{ env.NODE_IP }}" \
+ --harp --harp_shared_key "${{ env.HP_SHARED_KEY }}" \
+ --k8s --k8s_expose_type=nodeport --set-default
+ ./occ app_api:app:register app-skeleton-python k8s_test \
+ --info-xml https://raw.githubusercontent.com/nextcloud/app-skeleton-python/main/appinfo/info.xml \
+ --env='TEST_ENV_2=user_provided_value' --wait-finish
+
+ - name: Verify env vars on the freshly registered Deployment
+ run: |
+ kubectl -n nextcloud-exapps get deploy -l app.kubernetes.io/component=exapp -o json \
+ | python3 -c '
+ import json, sys
+ items = json.load(sys.stdin)["items"]
+ env = {}
+ for item in items:
+ for c in item["spec"]["template"]["spec"].get("containers", []):
+ for e in c.get("env", []):
+ env[e["name"]] = e.get("value", "")
+ assert env.get("TEST_ENV_1") == "0", f"TEST_ENV_1 default missing after register: {env}"
+ assert env.get("TEST_ENV_2") == "user_provided_value", f"TEST_ENV_2 user value missing after register: {env}"
+ '
+
+ - name: Seed stray ex_deploy_options row for a second app
+ # Without a second appid in the table, the Update.php bug from #808 is
+ # latent: formatDeployOptions() with no $appId filter still produces
+ # the single app's rows by luck. The `zz_` prefix ensures this stray row
+ # iterates AFTER `app-skeleton-python`, so the last-wins flattening
+ # actually clobbers the skeleton's env_vars entry.
+ run: |
+ php apps/${{ env.APP_NAME }}/tests/integration_helper.php \
+ set-env zz_fake_second_app UNRELATED_VAR x
+
+ - name: Build v2 info.xml with bumped version
+ run: |
+ curl -sS https://raw.githubusercontent.com/nextcloud/app-skeleton-python/main/appinfo/info.xml \
+ | sed 's#[^<]*#999.0.0#' > /tmp/info-v2.xml
+ grep -q '999.0.0' /tmp/info-v2.xml || { echo "version bump failed"; exit 1; }
+
+ - name: Update ExApp
+ run: |
+ ./occ app_api:app:update app-skeleton-python --info-xml /tmp/info-v2.xml --wait-finish
+
+ - name: After update, env vars still present on the new Deployment
+ run: |
+ kubectl -n nextcloud-exapps get deploy -l app.kubernetes.io/component=exapp -o json \
+ | python3 -c '
+ import json, sys
+ items = json.load(sys.stdin)["items"]
+ env = {}
+ for item in items:
+ for c in item["spec"]["template"]["spec"].get("containers", []):
+ for e in c.get("env", []):
+ env[e["name"]] = e.get("value", "")
+ assert env.get("TEST_ENV_1") == "0", f"#808 regression: TEST_ENV_1 lost on update; env={env}"
+ assert env.get("TEST_ENV_2") == "user_provided_value", f"#808 regression: TEST_ENV_2 user value lost on update; env={env}"
+ '
+
+ - name: Collect HaRP logs
+ if: always()
+ run: docker logs appapi-harp > harp.log 2>&1
+
+ - name: Collect K8s resources
+ if: always()
+ run: |
+ kubectl -n nextcloud-exapps get all -o wide > k8s-resources.txt 2>&1 || true
+ kubectl -n nextcloud-exapps describe pods > k8s-pods-describe.txt 2>&1 || true
+
+ - name: Upload HaRP logs
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: k8s_update_preserves_deploy_options_harp.log
+ path: harp.log
+ if-no-files-found: warn
+
+ - name: Upload K8s resources
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: k8s_update_preserves_deploy_options_resources.txt
+ path: |
+ k8s-resources.txt
+ k8s-pods-describe.txt
+ if-no-files-found: warn
+
+ - name: Upload NC logs
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: k8s_update_preserves_deploy_options_nextcloud.log
+ path: data/nextcloud.log
+ if-no-files-found: warn
+
tests-success:
permissions:
contents: none
runs-on: ubuntu-22.04
- needs: [k8s-deploy-nodeport]
+ needs: [k8s-deploy-nodeport, k8s-update-preserves-deploy-options]
name: K8s-NodePort-Tests-OK
steps:
- run: echo "K8s NodePort tests passed successfully"
diff --git a/.github/workflows/tests-deploy.yml b/.github/workflows/tests-deploy.yml
index c71fe2bc..0c840e5e 100644
--- a/.github/workflows/tests-deploy.yml
+++ b/.github/workflows/tests-deploy.yml
@@ -858,15 +858,18 @@ jobs:
- 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
+ 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
+ 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
+ if docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q '"TEST_ENV_3='; then
+ echo "TEST_ENV_3 should not be present in container env"
+ exit 1
+ fi
- name: Check docker inspect TEST_MOUNT
run: |
@@ -1563,6 +1566,162 @@ jobs:
if: always()
run: tail -v -n +1 *container.json *.log data/nextcloud.log
+ nc-update-preserves-deploy-options:
+ runs-on: ubuntu-22.04
+ name: Update preserves deploy options (Docker)
+ # Regression test for https://github.com/nextcloud/app_api/issues/808
+ # When two or more ExApps have rows in `ex_deploy_options`, the update flow
+ # used to drop the target app's env vars because it read the unfiltered
+ # options table. This job seeds a stray second row, bumps the skeleton's
+ # version via a local info.xml, runs `app_api:app:update`, and asserts the
+ # user-provided env vars are still set on the new container.
+
+ services:
+ postgres:
+ image: ghcr.io/nextcloud/continuous-integration-postgres-14:latest # zizmor: ignore[unpinned-images]
+ 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
+
+ steps:
+ - name: Set app env
+ run: echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV
+
+ - name: Checkout server
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ submodules: true
+ repository: nextcloud/server
+ ref: master
+
+ - name: Checkout AppAPI
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ path: apps/${{ env.APP_NAME }}
+
+ - name: Set up php
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 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
+ coverage: none
+ ini-file: development
+ ini-values:
+ apc.enabled=on, apc.enable_cli=on, disable_functions=
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check composer file existence
+ id: check_composer
+ uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # 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
+ 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 app:enable --force ${{ env.APP_NAME }}
+
+ - name: Register daemon & Skeleton v1 with user env vars
+ 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: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=user_provided_value'
+
+ - name: Verify env vars on the freshly registered container
+ run: |
+ docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q '"TEST_ENV_1=0"' \
+ || { echo "TEST_ENV_1 default missing after register"; exit 1; }
+ docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q '"TEST_ENV_2=user_provided_value"' \
+ || { echo "TEST_ENV_2 user value missing after register"; exit 1; }
+
+ - name: Seed stray ex_deploy_options row for a second app
+ # Without a second appid in the table, the Update.php bug from #808 is
+ # latent: formatDeployOptions() with no $appId filter still produces
+ # the single app's rows by luck. The `zz_` prefix ensures this stray row
+ # iterates AFTER `app-skeleton-python`, so the last-wins flattening
+ # actually clobbers the skeleton's env_vars entry.
+ run: |
+ php apps/${{ env.APP_NAME }}/tests/integration_helper.php \
+ set-env zz_fake_second_app UNRELATED_VAR x
+
+ - name: Build v2 info.xml with bumped version
+ # Update.php:153 short-circuits when versions match; bumping to 999.0.0
+ # forces the real update path to run without touching the image.
+ run: |
+ curl -sS https://raw.githubusercontent.com/nextcloud/app-skeleton-python/main/appinfo/info.xml \
+ | sed 's#[^<]*#999.0.0#' > /tmp/info-v2.xml
+ grep -q '999.0.0' /tmp/info-v2.xml || { echo "version bump failed"; exit 1; }
+
+ - name: Update ExApp
+ run: |
+ ./occ app_api:app:update app-skeleton-python --info-xml /tmp/info-v2.xml --wait-finish
+
+ - name: After update, TEST_ENV_1 default still present
+ run: |
+ docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q '"TEST_ENV_1=0"' \
+ || { echo "#808 regression: TEST_ENV_1 lost on update"; exit 1; }
+
+ - name: After update, TEST_ENV_2 user value still present
+ run: |
+ docker inspect --format '{{ json .Config.Env }}' nc_app_app-skeleton-python | grep -q '"TEST_ENV_2=user_provided_value"' \
+ || { echo "#808 regression: user-provided TEST_ENV_2 lost on update"; 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: Upload Container info
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nc_update_preserves_deploy_options_container.json
+ path: container.json
+ if-no-files-found: warn
+
+ - name: Upload Container logs
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nc_update_preserves_deploy_options_container.log
+ path: container.log
+ if-no-files-found: warn
+
+ - name: Upload NC logs
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nc_update_preserves_deploy_options_nextcloud.log
+ path: data/nextcloud.log
+ if-no-files-found: warn
+
tests-deploy-success:
permissions:
contents: none
@@ -1570,7 +1729,7 @@ jobs:
needs: [nc-host-app-docker, nc-docker-app-docker, nc-docker-dsp-http,
nc-docker-dsp-https, nc-host-app-docker-redis, nc-host-network-host,
nc-docker-harp-bridge, nc-docker-harp-bridge-no-tls, nc-host-harp-host,
- nc-host-manual-harp-host]
+ nc-host-manual-harp-host, nc-update-preserves-deploy-options]
name: Tests-Deploy-OK
steps:
- run: echo "Tests-Deploy passed successfully"
diff --git a/lib/Command/ExApp/Update.php b/lib/Command/ExApp/Update.php
index b73e33c5..39cb81ea 100644
--- a/lib/Command/ExApp/Update.php
+++ b/lib/Command/ExApp/Update.php
@@ -95,7 +95,7 @@ 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()
+ $this->exAppDeployOptionsService->getDeployOptions($appId)
);
$appInfo = $this->exAppService->getAppInfo(
$appId, $input->getOption('info-xml'), $input->getOption('json-info'),
diff --git a/tests/integration_helper.php b/tests/integration_helper.php
new file mode 100644
index 00000000..bd20a6e7
--- /dev/null
+++ b/tests/integration_helper.php
@@ -0,0 +1,65 @@
+
+ * php integration_helper.php remove
+ */
+
+require __DIR__ . '/../../../lib/base.php';
+
+OCP\Server::get(OCP\App\IAppManager::class)->loadApp('app_api');
+$service = OCP\Server::get(OCA\AppAPI\Service\ExAppDeployOptionsService::class);
+
+$command = $argv[1] ?? '';
+$appid = $argv[2] ?? '';
+
+if ($appid === '') {
+ fwrite(STDERR, "usage: integration_helper.php set-env \n");
+ fwrite(STDERR, " integration_helper.php remove \n");
+ exit(1);
+}
+
+switch ($command) {
+ case 'set-env':
+ $name = $argv[3] ?? '';
+ $value = $argv[4] ?? '';
+ if ($name === '') {
+ fwrite(STDERR, "missing \n");
+ exit(1);
+ }
+ $existing = $service->getDeployOption($appid, 'environment_variables');
+ $envVars = $existing !== null ? $existing->getValue() : [];
+ $envVars[$name] = ['name' => $name, 'value' => $value];
+ if ($service->addExAppDeployOption($appid, 'environment_variables', $envVars) === null) {
+ fwrite(STDERR, "failed to persist {$name}={$value} for {$appid}\n");
+ exit(1);
+ }
+ echo "set {$name}={$value} for {$appid}\n";
+ break;
+
+ case 'remove':
+ if ($service->removeExAppDeployOptions($appid) < 0) {
+ fwrite(STDERR, "failed to remove deploy options for {$appid}\n");
+ exit(1);
+ }
+ echo "removed deploy options for {$appid}\n";
+ break;
+
+ default:
+ fwrite(STDERR, "unknown command '{$command}'\n");
+ exit(1);
+}
diff --git a/tests/php/Service/ExAppDeployOptionsServiceTest.php b/tests/php/Service/ExAppDeployOptionsServiceTest.php
new file mode 100644
index 00000000..75a67a6e
--- /dev/null
+++ b/tests/php/Service/ExAppDeployOptionsServiceTest.php
@@ -0,0 +1,124 @@
+mapper = $this->createMock(ExAppDeployOptionsMapper::class);
+ $cacheFactory = $this->createMock(ICacheFactory::class);
+ $cacheFactory->method('isAvailable')->willReturn(false);
+
+ $this->service = new ExAppDeployOptionsService(
+ $this->createMock(LoggerInterface::class),
+ $this->mapper,
+ $cacheFactory,
+ );
+ }
+
+ /**
+ * Two apps with distinct env-var keys. Raw fetch returns both; filtered fetch
+ * returns only the requested app's rows.
+ */
+ public function testGetDeployOptionsFiltersByAppId(): void {
+ $this->mapper->method('findAll')->willReturn($this->twoAppRecords());
+
+ $all = $this->service->getDeployOptions();
+ self::assertCount(4, $all);
+
+ $appA = $this->service->getDeployOptions('app_a');
+ self::assertCount(2, $appA);
+ foreach ($appA as $entry) {
+ self::assertSame('app_a', $entry->getAppid());
+ }
+
+ $appB = $this->service->getDeployOptions('app_b');
+ self::assertCount(2, $appB);
+ foreach ($appB as $entry) {
+ self::assertSame('app_b', $entry->getAppid());
+ }
+ }
+
+ /**
+ * Regression guard for issue #808.
+ *
+ * `formatDeployOptions` keys by `type`, so passing the unfiltered result
+ * (rows for every app) causes the last app in iteration order to overwrite
+ * entries for earlier apps. Calling `getDeployOptions($appId)` first is the
+ * only way to get a clean per-app map.
+ */
+ public function testFormatDeployOptionsUnfilteredOverwritesAcrossApps(): void {
+ $this->mapper->method('findAll')->willReturn($this->twoAppRecords());
+
+ $unfiltered = $this->service->formatDeployOptions($this->service->getDeployOptions());
+ // Last app in iteration order wins: app_b's keys leak into the env_vars entry.
+ self::assertArrayHasKey('environment_variables', $unfiltered);
+ self::assertSame(
+ ['APP_B_VAR'],
+ array_keys($unfiltered['environment_variables']),
+ 'Unfiltered format is order-dependent: last app overwrites earlier apps.',
+ );
+
+ $filtered = $this->service->formatDeployOptions($this->service->getDeployOptions('app_a'));
+ self::assertSame(
+ ['APP_A_VAR_1', 'APP_A_VAR_2'],
+ array_keys($filtered['environment_variables']),
+ 'Filtered format returns only the requested app\'s env vars.',
+ );
+ }
+
+ /**
+ * @return list}>
+ */
+ private function twoAppRecords(): array {
+ return [
+ [
+ 'id' => 1,
+ 'appid' => 'app_a',
+ 'type' => 'environment_variables',
+ 'value' => [
+ 'APP_A_VAR_1' => ['name' => 'APP_A_VAR_1', 'value' => 'a1'],
+ 'APP_A_VAR_2' => ['name' => 'APP_A_VAR_2', 'value' => 'a2'],
+ ],
+ ],
+ [
+ 'id' => 2,
+ 'appid' => 'app_a',
+ 'type' => 'mounts',
+ 'value' => [],
+ ],
+ [
+ 'id' => 3,
+ 'appid' => 'app_b',
+ 'type' => 'environment_variables',
+ 'value' => [
+ 'APP_B_VAR' => ['name' => 'APP_B_VAR', 'value' => 'b1'],
+ ],
+ ],
+ [
+ 'id' => 4,
+ 'appid' => 'app_b',
+ 'type' => 'mounts',
+ 'value' => [],
+ ],
+ ];
+ }
+}