diff --git a/.docker/consumer-app.yml b/.docker/consumer-app.yml new file mode 100644 index 0000000..c1f0714 --- /dev/null +++ b/.docker/consumer-app.yml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +services: + nextcloud: + volumes: + - type: bind + source: ${APP_SOURCE:?APP_SOURCE must point to the downstream app checkout} + target: ${APP_CONTAINER_PATH:?APP_CONTAINER_PATH must be set by the consumer contract} + nginx: + volumes: + - type: bind + source: ${APP_SOURCE:?APP_SOURCE must point to the downstream app checkout} + target: ${APP_CONTAINER_PATH:?APP_CONTAINER_PATH must be set by the consumer contract} + read_only: true diff --git a/.github/workflows/proxy-tests.yml b/.github/workflows/proxy-tests.yml index 62a893a..ca9bc6c 100644 --- a/.github/workflows/proxy-tests.yml +++ b/.github/workflows/proxy-tests.yml @@ -155,3 +155,36 @@ jobs: if [ "$MARIADB_VERSION" = 10.11 ]; then DB_TYPE=mariadb sh ./dev-worker maria-peer destroy || true fi + + consumer-worker: + name: Downstream app consumer integration + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Build PHP 8.3 development image + run: docker build -f .docker/Dockerfile.php83 -t ghcr.io/librecodecoop/nextcloud-dev-php83:latest .docker + - name: Start two downstream consumers + run: | + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture APP_SETUP_HOOK=setup.sh DB_TYPE=sqlite sh ./dev-worker consumer-a up + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture APP_SETUP_HOOK=setup.sh DB_TYPE=sqlite sh ./dev-worker consumer-b up + - name: Verify source and hook isolation + run: | + test "$(APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-a exec cat /var/www/html/apps-extra/fixture/identity.txt)" = alpha + test "$(APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-b exec cat /var/www/html/apps-extra/fixture/identity.txt)" = beta + test "$(APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-a exec cat /var/www/html/data/consumer-hook)" = alpha + test "$(APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-b exec cat /var/www/html/data/consumer-hook)" = beta + - name: Verify teardown is worker-scoped + run: | + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-a destroy + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-b status | grep -q 'installed: true' + - name: Collect consumer logs + if: failure() + run: | + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-a logs || true + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-b logs || true + - name: Destroy remaining consumer + if: always() + run: | + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-a destroy || true + APP_SOURCE="$GITHUB_WORKSPACE/tests/worker/fixtures/consumer-b" APP_ID=fixture DB_TYPE=sqlite sh ./dev-worker consumer-b destroy || true diff --git a/README.md b/README.md index 21d20d3..c46dc6a 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,5 @@ and other advanced configuration, see the - [Advanced setup](docs/advanced-setup.md) - [App development](docs/apps-development.md) +- [Downstream app consumers](docs/downstream-consumers.md) - [FAQ](docs/faq.md) \ No newline at end of file diff --git a/dev-worker b/dev-worker index 2813185..2786315 100644 --- a/dev-worker +++ b/dev-worker @@ -25,6 +25,9 @@ Environment: PHP_VERSION= Existing PHP image selector VERSION_NEXTCLOUD= Existing Nextcloud ref selector WORKER_READY_TIMEOUT=180 Seconds to wait for a clean installation + APP_SOURCE= Optional downstream app checkout to mount + APP_ID= App directory name under apps-extra + APP_SETUP_HOOK= Optional POSIX shell hook relative to APP_SOURCE EOF } @@ -65,6 +68,34 @@ esac worker_root="$repo_root/.workers/$worker_id" worker_volumes_dir="$worker_root/volumes" +consumer_enabled=0 + +if [ -n "${APP_SOURCE:-}" ] || [ -n "${APP_ID:-}" ] || [ -n "${APP_SETUP_HOOK:-}" ]; then + [ -n "${APP_SOURCE:-}" ] || { echo "APP_SOURCE is required when configuring a downstream app" >&2; exit 2; } + [ -n "${APP_ID:-}" ] || { echo "APP_ID is required when configuring a downstream app" >&2; exit 2; } + [ -d "$APP_SOURCE" ] || { echo "APP_SOURCE is not a directory: $APP_SOURCE" >&2; exit 2; } + + case "$APP_ID" in + *[!a-z0-9_-]*|'') echo "Invalid APP_ID: $APP_ID" >&2; exit 2 ;; + esac + case "$APP_ID" in + [a-z0-9]*) ;; + *) echo "APP_ID must start with a lowercase letter or digit: $APP_ID" >&2; exit 2 ;; + esac + + if [ -n "${APP_SETUP_HOOK:-}" ]; then + case "$APP_SETUP_HOOK" in + /*|../*|*/../*|*/..) echo "APP_SETUP_HOOK must be a relative path without '..': $APP_SETUP_HOOK" >&2; exit 2 ;; + esac + [ -f "$APP_SOURCE/$APP_SETUP_HOOK" ] || { echo "APP_SETUP_HOOK does not exist: $APP_SOURCE/$APP_SETUP_HOOK" >&2; exit 2; } + fi + + APP_SOURCE="$(CDPATH= cd -- "$APP_SOURCE" && pwd)" + export APP_SOURCE + export APP_ID + export APP_CONTAINER_PATH="/var/www/html/apps-extra/$APP_ID" + consumer_enabled=1 +fi export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ncdev-$worker_id}" export WORKER_VOLUMES_DIR="$worker_volumes_dir" @@ -89,7 +120,13 @@ else fi compose() { - docker compose --project-directory "$repo_root" --file "$repo_root/docker-compose.yml" "$@" + if [ "$consumer_enabled" -eq 1 ]; then + docker compose --project-directory "$repo_root" \ + --file "$repo_root/docker-compose.yml" \ + --file "$repo_root/.docker/consumer-app.yml" "$@" + else + docker compose --project-directory "$repo_root" --file "$repo_root/docker-compose.yml" "$@" + fi } wait_for_mariadb() { @@ -151,6 +188,9 @@ case "$command" in compose up -d fi wait_until_ready + if [ "$consumer_enabled" -eq 1 ] && [ -n "${APP_SETUP_HOOK:-}" ]; then + compose exec -T -u www-data nextcloud sh "$APP_CONTAINER_PATH/$APP_SETUP_HOOK" + fi ;; exec) [ "$#" -gt 0 ] || { diff --git a/docs/apps-development.md b/docs/apps-development.md index e7ab966..09a6dda 100644 --- a/docs/apps-development.md +++ b/docs/apps-development.md @@ -1,29 +1,27 @@ -# Start development of apps - -You will need create (or clone) the folder of the app that you will work inside the folder `volumes/nextcloud/apps-extra`. - -It's not required install all dependencis like php or nodejs to develop apps, with this project is only use the bash in container to compile app. - -## Sample - -Using the [LibreSign](https://github.com/LibreSign/libresign): - -To install LibreSign in the structure of develop is required [up servicer](#up-services). After nextcloud config and install. - - open folder `volumes/nextcloud/app-extra` - - clone project with `git clone https://github.com/LibreSign/libresign.git` - - open bash in nextcloud container with `docker compose exec -u www-data nextcloud bash` - - go to folder `apps-extra/libresign` - ```bash - cd apps-extra/libresign - ``` - - Now you can run all the necessaries commands to build the project, i.e: - ```bash - # download composer dependencies - composer install - # download JS dependencies - npm ci - # build and watch JS changes - npm run watch - ``` +# Develop a Nextcloud app + +For a one-off local checkout, an app can still be placed under +`volumes/nextcloud/apps-extra` and used from the `nextcloud` container. + +For app repositories that need a reusable development/devcontainer contract, +prefer the [downstream app consumer](downstream-consumers.md) workflow instead +of cloning or copying the full Docker Compose topology. + +The consumer contract mounts the app checkout into `apps-extra` while this +repository remains responsible for Nextcloud, database, proxy, mail and runtime +services. + +Example: + +```bash +APP_SOURCE=/work/my-app APP_ID=my_app DB_TYPE=sqlite \ + sh ./dev-worker my-app up + +APP_SOURCE=/work/my-app APP_ID=my_app DB_TYPE=sqlite \ + sh ./dev-worker my-app exec sh -lc 'cd /var/www/html/apps-extra/my_app && composer install' +``` + +See [Downstream app consumers](downstream-consumers.md) for setup hooks and the +minimal Compose adapter used by devcontainers. ⬅️ [Back to index](../README.md) diff --git a/docs/downstream-consumers.md b/docs/downstream-consumers.md new file mode 100644 index 0000000..6294cd5 --- /dev/null +++ b/docs/downstream-consumers.md @@ -0,0 +1,66 @@ +# Downstream app consumers + +`nextcloud-docker-development` can provide the canonical Nextcloud runtime for +another app repository without copying the infrastructure topology into that +repository. + +## Worker contract + +Point the worker at an app checkout with: + +- `APP_SOURCE`: host path to the app checkout; +- `APP_ID`: directory name under `/var/www/html/apps-extra`; +- `APP_SETUP_HOOK`: optional POSIX shell script relative to the app checkout, + executed as the mapped `www-data` user after Nextcloud is ready. + +Example: + +```bash +APP_SOURCE=/work/my-app APP_ID=my_app APP_SETUP_HOOK=.devcontainer/setup.sh \ + DB_TYPE=sqlite sh ./dev-worker my-app-worker up + +APP_SOURCE=/work/my-app APP_ID=my_app DB_TYPE=sqlite \ + sh ./dev-worker my-app-worker exec occ app:enable my_app + +APP_SOURCE=/work/my-app APP_ID=my_app DB_TYPE=sqlite \ + sh ./dev-worker my-app-worker destroy +``` + +The app is mounted into the Nextcloud and nginx services. The normal worker +contract still owns Compose naming, mutable state, database selection, runtime +logs and teardown. + +## Devcontainer adapter + +Docker Compose `include` can keep the infrastructure model owned by this +repository while a downstream repository keeps only a small adapter. + +A consumer can keep a local Compose file similar to: + +```yaml +include: + - path: + - ${NCDD_ROOT:?set NCDD_ROOT}/docker-compose.yml + - ${NCDD_ROOT:?set NCDD_ROOT}/.docker/consumer-app.yml + project_directory: ${NCDD_ROOT:?set NCDD_ROOT} +``` + +Set `APP_SOURCE` to an absolute path, `APP_ID` to the app directory name +and `APP_CONTAINER_PATH=/var/www/html/apps-extra/$APP_ID` before running Compose. +The downstream devcontainer can then use the included `nextcloud` service as its +runtime service. + +This adapter does not redefine Nextcloud, database, proxy, mail or Redis +services. Changes to that infrastructure remain owned by +`nextcloud-docker-development`. + +Docker Compose 2.20+ is required for the top-level `include` feature. + +## Isolation + +Each worker still receives its own Compose project and mutable directory. Two +consumers may therefore use the same `APP_ID` and container path while mounting +different source checkouts without sharing Nextcloud state. + +The worker ID is an isolation namespace for development convenience, not a +security boundary between untrusted workloads sharing the same Docker daemon. diff --git a/tests/worker/contract.bats b/tests/worker/contract.bats index 668074f..20adfec 100644 --- a/tests/worker/contract.bats +++ b/tests/worker/contract.bats @@ -74,3 +74,18 @@ setup() { grep -q 'pdo_sqlite' "$dockerfile" done } + +@test "downstream app contract mounts an app checkout without duplicating the base topology" { + run env APP_SOURCE="$REPO_ROOT/tests/worker/fixtures/consumer-a" APP_ID=fixture DB_TYPE=sqlite sh "$WORKER" consumer-a config + [ "$status" -eq 0 ] + [[ "$output" == *"$REPO_ROOT/tests/worker/fixtures/consumer-a"* ]] + [[ "$output" == *"/var/www/html/apps-extra/fixture"* ]] +} + +@test "downstream app contract rejects unsafe identifiers and setup hook traversal" { + run env APP_SOURCE="$REPO_ROOT/tests/worker/fixtures/consumer-a" APP_ID='../fixture' DB_TYPE=sqlite sh "$WORKER" consumer-a config + [ "$status" -eq 2 ] + + run env APP_SOURCE="$REPO_ROOT/tests/worker/fixtures/consumer-a" APP_ID=fixture APP_SETUP_HOOK='../setup.sh' DB_TYPE=sqlite sh "$WORKER" consumer-a config + [ "$status" -eq 2 ] +} diff --git a/tests/worker/fixtures/consumer-a/identity.txt b/tests/worker/fixtures/consumer-a/identity.txt new file mode 100644 index 0000000..4a58007 --- /dev/null +++ b/tests/worker/fixtures/consumer-a/identity.txt @@ -0,0 +1 @@ +alpha diff --git a/tests/worker/fixtures/consumer-a/setup.sh b/tests/worker/fixtures/consumer-a/setup.sh new file mode 100644 index 0000000..8d8cf93 --- /dev/null +++ b/tests/worker/fixtures/consumer-a/setup.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -eu +cat "$(dirname "$0")/identity.txt" > /var/www/html/data/consumer-hook diff --git a/tests/worker/fixtures/consumer-b/identity.txt b/tests/worker/fixtures/consumer-b/identity.txt new file mode 100644 index 0000000..65b2df8 --- /dev/null +++ b/tests/worker/fixtures/consumer-b/identity.txt @@ -0,0 +1 @@ +beta diff --git a/tests/worker/fixtures/consumer-b/setup.sh b/tests/worker/fixtures/consumer-b/setup.sh new file mode 100644 index 0000000..8d8cf93 --- /dev/null +++ b/tests/worker/fixtures/consumer-b/setup.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +set -eu +cat "$(dirname "$0")/identity.txt" > /var/www/html/data/consumer-hook