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' => [], + ], + ]; + } +}