diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index a58da5d..beafbb8 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -1,7 +1,12 @@ # LOCAL development stack — runs with a plain `docker compose up`, no make / no -# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or +# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or # macOS, or rootless Podman on Linux). # +# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the +# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot +# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up +# completes the whole flow with no manual steps. `make verify-local` asserts it. +# # docker compose -f infra/docker-compose.local.yml up -d --build # podman # docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop # docker compose -f infra/docker-compose.local.yml down --volumes @@ -257,28 +262,65 @@ services: restart: "no" volumes: - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z + - ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z command: - sh - -c - | - base=http://flowable-rest:8080/flowable-rest/service/repository/deployments - until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done - if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then - echo "registratie already deployed; skip" + svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments + dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments + until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done + # Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments: + # flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN + # must go via dmn-api. The registratie process's DMN service task then resolves the decision across + # deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the + # missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent. + if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then + echo "diploma-eligibility DMN already deployed; skip" else - curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie" + curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN" + fi + if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then + echo "registratie BPMN already deployed; skip" + else + curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN" fi depends_on: flowable-rest: condition: service_started networks: [cg] + # ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ───────────────── + # The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this + # file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype) + # and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on + # startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI. + # Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator. + local-seed: + image: docker.io/library/python:3-slim + restart: "no" + volumes: + - ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z + - ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z + - seed-env:/out + command: ["sh", "/work/seed-zaaktype.sh"] + depends_on: + openzaak: + condition: service_healthy + networks: [cg] + # ── ACL ────────────────────────────────────────────────────────────────── acl: build: context: ../services/acl dockerfile: Dockerfile image: register-referentie/acl:dev + # The base/zaaktype/informatieobjecttype below are PLACEHOLDERS. The real, server-assigned + # values are written by the local-seed one-shot into seed-env:/seed/acl.env, which the entrypoint + # sources (set -a) so they override these before the app starts (S-B04, #110, ADR-0020). Sourcing + # a runtime-generated env file is why we override the entrypoint here rather than use `env_file:` + # (which compose reads at parse time, before the seed has run). + entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"] environment: Acl__OpenZaak__BaseUrl: http://openzaak:8000/ Acl__OpenZaak__ClientId: big-reference-seed @@ -286,9 +328,12 @@ services: Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar - Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} + Acl__Defaults__ZaaktypeUrl: http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000 + Acl__Defaults__InformatieobjecttypeUrl: http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000 ports: - "8100:8080" + volumes: + - seed-env:/seed:ro healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] interval: 5s @@ -298,6 +343,8 @@ services: depends_on: openzaak: condition: service_healthy + local-seed: + condition: service_completed_successfully networks: [cg] # ── BFF ────────────────────────────────────────────────────────────────── @@ -400,6 +447,31 @@ services: condition: service_healthy networks: [cg] + # ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ────────────────────── + # Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the + # projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the + # `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is + # infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a + # single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait + # for the event-subscriber without creating a cycle with the ACL bootstrap. + nrc-subscribe: + image: docker.io/library/python:3-slim + restart: "no" + volumes: + - ./local/register-abonnement.py:/work/register-abonnement.py:ro,z + environment: + NRC_BASE: http://nrc-web:8000 + SINK_HOST: event-subscriber + SINK_PORT: "8080" + SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} + command: ["python", "/work/register-abonnement.py"] + depends_on: + nrc-web: + condition: service_healthy + event-subscriber: + condition: service_started + networks: [cg] + projection-api: build: context: .. @@ -492,6 +564,8 @@ volumes: nrc-db: flowable-db: projection-db: + # Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL. + seed-env: networks: cg: diff --git a/infra/local/register-abonnement.py b/infra/local/register-abonnement.py new file mode 100755 index 0000000..8ce50c1 --- /dev/null +++ b/infra/local/register-abonnement.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement. + +Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an +abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so +OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar +(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus +the test zaak it also creates). + +The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because +NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the +zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications +abonnement first, then registers one for the current IP. Stdlib only. + +Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET. +""" +import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request + +NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/") +SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber") +SINK_PORT = os.environ.get("SINK_PORT", "8080") +SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications") +CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") +SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") + + +def token(): + b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") + seg = ( + b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + + b"." + + b64(json.dumps( + {"iss": CID, "iat": int(time.time()), "client_id": CID, + "user_id": "local-seed", "user_representation": "local-seed"}, + separators=(",", ":")).encode()) + ) + return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode() + + +def call(method, url, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": "Bearer " + token(), + "Content-Type": "application/json", "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + raw = e.read() + return e.code, (json.loads(raw) if raw else None) + + +def main(): + ip = socket.gethostbyname(SINK_HOST) + callback = f"http://{ip}:{SINK_PORT}/notifications" + + # Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a + # fresh one for the current event-subscriber IP. + status, body = call("GET", f"{NRC}/api/v1/abonnement") + for ab in (body or []) if status == 200 else []: + if str(ab.get("callbackUrl", "")).endswith("/notifications"): + if ab.get("callbackUrl") == callback: + print(f"abonnement already current: {ab['url']}") + return + call("DELETE", ab["url"]) + print(f"removed stale abonnement {ab['url']}") + + status, ab = call("POST", f"{NRC}/api/v1/abonnement", { + "callbackUrl": callback, "auth": SINK_AUTH, + "kanalen": [{"naam": "zaken", "filters": {}}]}) + if status != 201: + sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}") + print(f"abonnement registered: {ab['url']} -> {callback}") + + +if __name__ == "__main__": + main() diff --git a/infra/local/seed-zaaktype.sh b/infra/local/seed-zaaktype.sh new file mode 100755 index 0000000..5773ad4 --- /dev/null +++ b/infra/local/seed-zaaktype.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step. +# +# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes +# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting +# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This +# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is +# assigned by OpenZaak at creation, so it can't be a static value in the compose file. +# +# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params +# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`. +# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which +# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020. +set -eu + +oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")" +OZ_BASE="http://${oz_ip}:8000" +export OZ_BASE OZ_PUBLISH=1 + +echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)" +out="$(python3 /work/seed_catalogus.py)" +echo "$out" + +zt="$(printf '%s\n' "$out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)" +iot="$(printf '%s\n' "$out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)" +[ -n "$zt" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } +[ -n "$iot" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; } + +# The ACL entrypoint sources this; these keys override the placeholder defaults in the compose file. +cat > /out/acl.env <> wrote /out/acl.env (base=${OZ_BASE}/ zaaktype=${zt})"