From fa8382fc024175ad3ce8d3209b695142eae9eb43 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 15:11:24 +0200 Subject: [PATCH] ci(infra): run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add projection-db + the two services to both compose files (host ports 8110/8120), their Dockerfiles (repo-root context — they share Projection.ReadModel), and a runner-safe verify-projection check (infra/run-projection-check.sh) that registers the abonnement at the real subscriber, creates a zaak and asserts projection-api serves an INGEDIEND row. Wire it into make (verify-projection, verify, WAIT_SVCS) and the CI verify-stack job, and run the event-subscriber Stryker ratchet in `make mutation` + upload its report. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 10 +++- Makefile | 23 ++++++---- infra/docker-compose.local.yml | 58 ++++++++++++++++++++++++ infra/docker-compose.yml | 68 ++++++++++++++++++++++++++++ infra/run-projection-check.sh | 68 ++++++++++++++++++++++++++++ services/event-subscriber/Dockerfile | 32 +++++++++++++ services/projection-api/Dockerfile | 31 +++++++++++++ 7 files changed, 281 insertions(+), 9 deletions(-) create mode 100755 infra/run-projection-check.sh create mode 100644 services/event-subscriber/Dockerfile create mode 100644 services/projection-api/Dockerfile diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b7c4067..8b90f88 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -62,6 +62,12 @@ jobs: name: acl-mutation-report path: services/acl/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn + - uses: https://github.com/actions/upload-artifact@v3 + if: always() + with: + name: event-subscriber-mutation-report + path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html + if-no-files-found: warn # One stage for every check that needs the live stack. On the single self-hosted # runner jobs run sequentially, so booting OpenZaak once (instead of once per job) @@ -80,10 +86,12 @@ jobs: run: make verify-acl - name: OpenZaak → NRC notification delivery run: make verify-nrc + - name: OpenZaak → NRC → Event Subscriber → projection-api + run: make verify-projection # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-init keycloak acl bff 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-init keycloak acl bff projection-db event-subscriber projection-api 2>&1 || true - name: Tear down if: always() run: make down diff --git a/Makefile b/Makefile index 76f3f19..0f8d469 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml # Long-running services with a healthcheck — the smoke polls these for readiness # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. -WAIT_SVCS := openzaak nrc-web acl bff +WAIT_SVCS := openzaak nrc-web acl bff event-subscriber projection-api # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # into external named volumes via `docker cp` (infra/seed-config.sh) instead of # bind-mounted, because bind mounts don't reach sibling containers on the @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit mutation integration verify verify-up verify-acl verify-nrc verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help +.PHONY: ci lint build unit mutation integration verify verify-up verify-acl verify-nrc verify-projection verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help ## ci: run the full pipeline — lint, build, unit, mutation, verify (mirrors Gitea Actions) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -61,14 +61,15 @@ build: unit: dotnet test $(SLN) -c Release --filter "Category!=Integration" -## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline) +## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` -# makes `make mutation` work from a fresh clone. Config + break threshold (the ratchet, -# CLAUDE.md §5) live in services/acl/stryker-config.json. The ACL is the first service -# with branching logic, so it sets the repo-wide baseline; later slices ratchet it up. +# makes `make mutation` work from a fresh clone. Each service owns its config + break +# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json and +# services/event-subscriber/stryker-config.json. Scores never regress below baseline. mutation: dotnet tool restore cd services/acl && dotnet stryker + cd services/event-subscriber && dotnet stryker ## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down # SEED populates the external config volumes first (upstream images used verbatim; @@ -130,7 +131,12 @@ verify-acl: verify-nrc: bash infra/run-notification-check.sh -## verify: local mirror of the CI verify-stack job — full stack up once, both checks, +## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06), +## against the already-running stack. +verify-projection: + bash infra/run-projection-check.sh + +## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. verify: @@ -139,7 +145,8 @@ verify: @bash -c 'set -e; rc=0; \ WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \ && bash infra/run-acl-integration.sh \ - && bash infra/run-notification-check.sh || rc=$$?; \ + && bash infra/run-notification-check.sh \ + && bash infra/run-projection-check.sh || rc=$$?; \ docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \ docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ exit $$rc' diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index f2c59dc..18d3f68 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -305,10 +305,68 @@ services: start_period: 10s networks: [cg] + # ── Read projection (S-06) ──────────────────────────────────────────────── + projection-db: + image: docker.io/library/postgres:16 + environment: + POSTGRES_USER: projection + POSTGRES_PASSWORD: projection + POSTGRES_DB: projection + volumes: + - projection-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U projection -d projection"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + event-subscriber: + build: + context: .. + dockerfile: services/event-subscriber/Dockerfile + image: register-referentie/event-subscriber:dev + environment: + ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} + ports: + - "8110:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 15s + depends_on: + projection-db: + condition: service_healthy + networks: [cg] + + projection-api: + build: + context: .. + dockerfile: services/projection-api/Dockerfile + image: register-referentie/projection-api:dev + environment: + ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + ports: + - "8120:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 15s + depends_on: + projection-db: + condition: service_healthy + networks: [cg] + volumes: oz-db: nrc-db: flowable-db: + projection-db: networks: cg: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index bc53e28..ecbe7bd 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -9,6 +9,8 @@ # 8080 BFF GET /health → Healthy # 8090 Flowable REST http://localhost:8090/flowable-rest/service/ # 8100 ACL GET /health → Healthy POST /zaken +# 8110 Event Subscriber GET /health → Healthy POST /notifications +# 8120 projection-api GET /health → Healthy GET /register # 8180 Keycloak (admin: admin / admin) # # docker compose -f infra/docker-compose.yml up -d --build --wait @@ -320,10 +322,76 @@ services: start_period: 10s networks: [cg] + # ── Read projection (S-06) ──────────────────────────────────────────────── + # One Postgres DB backing the rebuildable read projection (PRD §8.4): the Event + # Subscriber writes it, projection-api reads it. See ADR-0008. + projection-db: + image: docker.io/library/postgres:16 + environment: + POSTGRES_USER: projection + POSTGRES_PASSWORD: projection + POSTGRES_DB: projection + volumes: + - projection-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U projection -d projection"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + # Consumes NRC notifications (abonnement callback) and projects zaak-created events + # into register_projection. Build context is the repo root: it shares the read model + # in services/projection-api/Projection.ReadModel. + event-subscriber: + build: + context: .. + dockerfile: services/event-subscriber/Dockerfile + image: register-referentie/event-subscriber:dev + environment: + ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + # The bearer Open Notificaties must present on the abonnement callback. NRC's + # registration probe expects a 401 without it (ADR-0007). Dev-only token. + EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} + ports: + - "8110:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 15s + depends_on: + projection-db: + condition: service_healthy + networks: [cg] + + # The read side of the projection. Shares Projection.ReadModel, so build context is root. + projection-api: + build: + context: .. + dockerfile: services/projection-api/Dockerfile + image: register-referentie/projection-api:dev + environment: + ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection + ports: + - "8120:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 15s + depends_on: + projection-db: + condition: service_healthy + networks: [cg] + volumes: oz-db: nrc-db: flowable-db: + projection-db: # Config volumes — created and populated out-of-band by infra/seed-config.sh # (docker cp), because bind mounts don't reach sibling containers on the CI # runner. `external` keeps the names deterministic; the seed step manages them. diff --git a/infra/run-projection-check.sh b/infra/run-projection-check.sh new file mode 100755 index 0000000..6499277 --- /dev/null +++ b/infra/run-projection-check.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack: +# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG +# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real +# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak, +# and asserts projection-api serves a row for that zaak with status INGEDIEND. +# +# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and +# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the +# notification driver to register the abonnement + create the zaak. Does NOT manage the stack +# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}" + +cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; } +trap cleanup EXIT + +ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; } + +oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)" +nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)" +es="$(docker ps -q --filter 'name=event-subscriber' | head -1)" +proj="$(docker ps -q --filter 'name=projection-api' | head -1)" +[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; } +[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; } +net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)" +oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")" +echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip" + +echo ">> seeding a published BIG zaaktype (idempotent)" +sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \ + python:3-slim python /seed.py)" +docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null +docker start -a "$sid" +docker rm -f "$sid" >/dev/null + +echo ">> registering abonnement at the Event Subscriber + creating a zaak" +docker rm -f rr-pverify >/dev/null 2>&1 || true +drv="$(docker create --network "$net" --name rr-pverify \ + -e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \ + -e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \ + python:3-slim python /driver.py)" +docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null +docker start -a "$drv" +zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)" +docker rm -f rr-pverify >/dev/null +[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; } +zaak_uuid="${zaak_url##*/}" +echo ">> zaak created: $zaak_url" + +echo ">> polling projection-api for the projected row (status INGEDIEND)" +for _ in $(seq 1 30); do + body="$(docker run --rm --network "$net" curlimages/curl:latest \ + -fsS "http://$proj_ip:8080/register/$zaak_uuid" 2>/dev/null || true)" + if echo "$body" | grep -q '"INGEDIEND"'; then + echo "OK — projection-api serves zaak $zaak_uuid with status INGEDIEND" + echo "$body" | cut -c1-300 + exit 0 + fi + sleep 2 +done +echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2 +echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2 +echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2 +exit 1 diff --git a/services/event-subscriber/Dockerfile b/services/event-subscriber/Dockerfile new file mode 100644 index 0000000..a1f1fea --- /dev/null +++ b/services/event-subscriber/Dockerfile @@ -0,0 +1,32 @@ +# Multi-stage build for the Event Subscriber service (.NET 10). +# Build context is services/event-subscriber, but it references the shared read model in +# services/projection-api, so the context is the repo root (see infra/docker-compose.yml). +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore first (cached unless .csproj files change). +COPY services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj services/event-subscriber/EventSubscriber.Api/ +COPY services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj services/event-subscriber/EventSubscriber.Application/ +COPY services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj services/projection-api/Projection.ReadModel/ +RUN dotnet restore services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj + +COPY services/event-subscriber/ services/event-subscriber/ +COPY services/projection-api/Projection.ReadModel/ services/projection-api/Projection.ReadModel/ +RUN dotnet publish services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=5 \ + CMD curl -fsS http://localhost:8080/health || exit 1 + +ENTRYPOINT ["dotnet", "EventSubscriber.Api.dll"] diff --git a/services/projection-api/Dockerfile b/services/projection-api/Dockerfile new file mode 100644 index 0000000..6342a98 --- /dev/null +++ b/services/projection-api/Dockerfile @@ -0,0 +1,31 @@ +# Multi-stage build for the projection-api service (.NET 10). +# Build context is the repo root (it shares Projection.ReadModel — see infra/docker-compose.yml). +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore first (cached unless .csproj files change). +COPY services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj services/projection-api/ProjectionApi.Api/ +COPY services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj services/projection-api/Projection.ReadModel/ +COPY services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj services/event-subscriber/EventSubscriber.Application/ +RUN dotnet restore services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj + +COPY services/projection-api/ services/projection-api/ +COPY services/event-subscriber/EventSubscriber.Application/ services/event-subscriber/EventSubscriber.Application/ +RUN dotnet publish services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=5 \ + CMD curl -fsS http://localhost:8080/health || exit 1 + +ENTRYPOINT ["dotnet", "ProjectionApi.Api.dll"]