diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 8814794..f633326 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -1,5 +1,5 @@ -name: Build and scan runtime images -description: Build both runtime images for amd64 and arm64, then scan each image +name: Build, scan, and validate runtime images +description: Build and scan both runtime images, then run app image acceptance tests inputs: nextcloud_version: description: Nextcloud version passed to the app image build @@ -7,6 +7,14 @@ inputs: runs: using: composite steps: + - name: Setup Bats + uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + - name: Set up QEMU uses: docker/setup-qemu-action@v3 with: @@ -73,3 +81,15 @@ runs: 'app@linux/arm64=scan/app:arm64' \ 'web@linux/amd64=scan/web:amd64' \ 'web@linux/arm64=scan/web:arm64' + + - name: Runtime acceptance app image (linux/amd64) + shell: bash + env: + APP_IMAGE: scan/app:amd64 + run: bats tests/app-image.bats + + - name: Runtime acceptance app image (linux/arm64) + shell: bash + env: + APP_IMAGE: scan/app:arm64 + run: bats tests/app-image.bats diff --git a/Makefile b/Makefile index fa9655d..16e5fcc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ COMPOSE ?= docker compose GARAGES3_COMPOSE_FILE ?= docker-compose-garages3.yml +APP_TEST_IMAGE ?= nextcloud-app:acceptance -.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-hooks test-scan-images test-ncdd scan-images +.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-hooks test-scan-images test-ncdd test-app-image test-current-app-image scan-images up-garages3: $(COMPOSE) -f $(GARAGES3_COMPOSE_FILE) up -d garage @@ -35,6 +36,18 @@ test-scan-images: test-ncdd: bats tests/ncdd.bats +test-app-image: + @test -n "$(APP_IMAGE)" || { echo 'Usage: make test-app-image APP_IMAGE=' >&2; exit 2; } + APP_IMAGE="$(APP_IMAGE)" bats tests/app-image.bats + +test-current-app-image: + @set -e; \ + version="$$(sed -n 's/^NEXTCLOUD_VERSION=//p' .env.example | head -n 1)"; \ + test -n "$$version" || { echo 'NEXTCLOUD_VERSION is missing from .env.example' >&2; exit 1; }; \ + docker buildx build --platform linux/amd64 --load --tag "$(APP_TEST_IMAGE)" \ + --build-arg "NEXTCLOUD_VERSION=$$version" --file .docker/app/Dockerfile .docker/app; \ + $(MAKE) test-app-image APP_IMAGE="$(APP_TEST_IMAGE)" + scan-images: @set -e; \ version="$$(sed -n 's/^NEXTCLOUD_VERSION=//p' .env.example | head -n 1)"; \ diff --git a/docs/images.md b/docs/images.md index af436fd..a04aa50 100644 --- a/docs/images.md +++ b/docs/images.md @@ -149,6 +149,26 @@ Immutable web image: ghcr.io/librecodecoop/nextcloud-docker-web@sha256: ``` +## Runtime acceptance + +The app image has a runtime acceptance test based on the same behavioral checks used by the official Nextcloud container projects. + +The test operates on an already-built local image. It does not rebuild the image and does not use the repository deployment Compose files: + +```bash +make test-app-image APP_IMAGE=scan/app:amd64 +``` + +For a local build of the current app image followed by the same acceptance test: + +```bash +make test-current-app-image +``` + +The Bats acceptance test creates an isolated Docker network and PostgreSQL container, starts the image with Nextcloud autoinstall variables, waits for the installation, runs `occ status` and `occ check`, and sends a FastCGI request through the FPM runtime. Test-created resources are removed on success and failure. + +CI must run this test against the exact locally loaded app images produced by the build step. Runtime acceptance is a publication gate alongside vulnerability scanning; a separate deployment-stack test is not required for this contract. + ## Implementation boundary This document defines the target contract. It does not by itself migrate the current Dockerfiles, workflows, or historical tags. diff --git a/tests/app-image.bats b/tests/app-image.bats new file mode 100644 index 0000000..6ba75d2 --- /dev/null +++ b/tests/app-image.bats @@ -0,0 +1,204 @@ +#!/usr/bin/env bats + +setup() { + : "${APP_IMAGE:?APP_IMAGE must reference an already-built local image}" + + command -v docker >/dev/null 2>&1 || { + echo "Docker is required to run the app image acceptance test." >&2 + return 127 + } + + docker image inspect "$APP_IMAGE" >/dev/null 2>&1 || { + echo "Image not found locally: $APP_IMAGE" >&2 + return 2 + } + + POSTGRES_IMAGE=${NEXTCLOUD_IMAGE_TEST_POSTGRES_IMAGE:-postgres:18-alpine} + TIMEOUT_SECONDS=${NEXTCLOUD_IMAGE_TEST_TIMEOUT:-300} + RUNTIME_USER=${NEXTCLOUD_IMAGE_TEST_RUNTIME_USER:-www-data} + + IMAGE_ARCH=$(docker image inspect --format '{{.Architecture}}' "$APP_IMAGE") + case "$IMAGE_ARCH" in + amd64|arm64) IMAGE_PLATFORM="linux/$IMAGE_ARCH" ;; + *) + echo "Unsupported image architecture: $IMAGE_ARCH" >&2 + return 2 + ;; + esac + + TEST_ID="nextcloud-app-test-$$-${BATS_TEST_NUMBER}-${RANDOM}" + NETWORK_NAME="$TEST_ID" + DB_CONTAINER="${TEST_ID}-db" + APP_CONTAINER="${TEST_ID}-app" + FCGI_CLIENT_IMAGE="nextcloud-app-test-fcgi-client" + + DB_USER=nextcloud + DB_DATABASE=nextcloud + DB_PASSWORD="test-${RANDOM}-${RANDOM}-password" + ADMIN_USER=test_admin + ADMIN_PASSWORD="test-${RANDOM}-${RANDOM}-admin-password" + + NETWORK_CREATED=false + DB_CREATED=false + APP_CREATED=false +} + +teardown() { + if $APP_CREATED; then + docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true + fi + if $DB_CREATED; then + docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true + fi + if $NETWORK_CREATED; then + docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true + fi +} + +diagnostics() { + echo + echo "=== app container logs ===" >&2 + if $APP_CREATED; then + docker logs "$APP_CONTAINER" >&2 || true + else + echo "app container was not created" >&2 + fi + + echo + echo "=== postgres container logs ===" >&2 + if $DB_CREATED; then + docker logs "$DB_CONTAINER" >&2 || true + else + echo "postgres container was not created" >&2 + fi +} + +fail_with_diagnostics() { + local message=$1 + diagnostics + echo "$message" >&2 + return 1 +} + +wait_until() { + local description=$1 + shift + + local started now + started=$(date +%s) + + while ! "$@"; do + now=$(date +%s) + if [ $((now - started)) -ge "$TIMEOUT_SECONDS" ]; then + fail_with_diagnostics "Timed out after ${TIMEOUT_SECONDS}s waiting for $description." + return 1 + fi + sleep 2 + done +} + +postgres_ready() { + docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_DATABASE" >/dev/null 2>&1 +} + +nextcloud_installed() { + local status + status=$(docker exec -u "$RUNTIME_USER" "$APP_CONTAINER" php occ status --output=json 2>/dev/null) || return 1 + grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$status" +} + +ensure_fcgi_client() { + if docker image inspect "$FCGI_CLIENT_IMAGE" >/dev/null 2>&1; then + return + fi + + docker build -q -t "$FCGI_CLIENT_IMAGE" - <<'EOF' >/dev/null +FROM debian:trixie-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends libfcgi-bin \ + && rm -rf /var/lib/apt/lists/* +ENTRYPOINT ["cgi-fcgi"] +EOF +} + +fcgi_status() { + docker run --rm -i \ + --network "$NETWORK_NAME" \ + -e REQUEST_METHOD=GET \ + -e SCRIPT_NAME=/status.php \ + -e SCRIPT_FILENAME=/var/www/html/status.php \ + "$FCGI_CLIENT_IMAGE" \ + -bind -connect app:9000 2>/dev/null +} + +fpm_ready() { + local response + response=$(fcgi_status) || return 1 + grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$response" +} + +@test "app image installs Nextcloud and serves it through FPM" { + echo "Testing $APP_IMAGE ($IMAGE_PLATFORM)" + + run docker network create "$NETWORK_NAME" + [ "$status" -eq 0 ] + NETWORK_CREATED=true + + run docker run -d \ + --name "$DB_CONTAINER" \ + --network "$NETWORK_NAME" \ + --network-alias db \ + -e "POSTGRES_USER=$DB_USER" \ + -e "POSTGRES_PASSWORD=$DB_PASSWORD" \ + -e "POSTGRES_DB=$DB_DATABASE" \ + "$POSTGRES_IMAGE" + [ "$status" -eq 0 ] + DB_CREATED=true + + wait_until "PostgreSQL readiness" postgres_ready + + run docker run -d \ + --name "$APP_CONTAINER" \ + --network "$NETWORK_NAME" \ + --network-alias app \ + --platform "$IMAGE_PLATFORM" \ + -e POSTGRES_HOST=db \ + -e "POSTGRES_USER=$DB_USER" \ + -e "POSTGRES_PASSWORD=$DB_PASSWORD" \ + -e "POSTGRES_DB=$DB_DATABASE" \ + -e "NEXTCLOUD_ADMIN_USER=$ADMIN_USER" \ + -e "NEXTCLOUD_ADMIN_PASSWORD=$ADMIN_PASSWORD" \ + -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \ + "$APP_IMAGE" + [ "$status" -eq 0 ] + APP_CREATED=true + + wait_until "Nextcloud installation" nextcloud_installed + + run docker exec -u "$RUNTIME_USER" "$APP_CONTAINER" php occ status --output=json + if [ "$status" -ne 0 ]; then + fail_with_diagnostics "occ status failed." + fi + if ! grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$output"; then + printf '%s\n' "$output" >&2 + fail_with_diagnostics "occ status did not report installed: true." + fi + + run docker exec -u "$RUNTIME_USER" "$APP_CONTAINER" php occ check + if [ "$status" -ne 0 ]; then + fail_with_diagnostics "occ check failed." + fi + + ensure_fcgi_client + + wait_until "FPM readiness" fpm_ready + + run fcgi_status + if [ "$status" -ne 0 ]; then + fail_with_diagnostics "FPM did not accept the FastCGI request after becoming ready." + fi + if ! grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$output"; then + printf '%s\n' "$output" >&2 + fail_with_diagnostics "FPM response did not report installed: true." + fi +}