From dc9ca2ceb2f92617317468a28d6afe1dc623e1dd Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 11:50:16 +0200 Subject: [PATCH 1/4] test(infra): verify-objecten-notifications asserts Objecten publishes to NRC (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers an abonnement on the `objecten` kanaal pointing at the existing webhook sink, writes a RegisterRecord exactly as the ACL does on approval, and waits for the delivery. That is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat → the callback. Fails today at the first hop, which is the point: NRC POST /api/v1/abonnement → 400: {"name":"naam","code":"kanaal_naam", "reason":"Kanaal met deze naam bestaat niet."} Also splits S-19b (#150) into #152/#153 in BACKLOG.md — the notification wiring and the projection re-source are independently deployable (CLAUDE.md §13). --- .gitea/workflows/ci.yaml | 7 +- BACKLOG.md | 4 +- Makefile | 8 +- infra/objecten-notifications-check.py | 119 ++++++++++++++++++++++ infra/run-objecten-notifications-check.sh | 80 +++++++++++++++ 5 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 infra/objecten-notifications-check.py create mode 100755 infra/run-objecten-notifications-check.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b5d96df..1441ed4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -219,6 +219,9 @@ jobs: - name: OpenZaak → NRC → Event Subscriber → projection-api id: projection run: make verify-projection + - name: Objecten → NRC notification delivery + id: objecten_nrc + run: make verify-objecten-notifications - name: Domain → Flowable → ACL → OpenZaak id: domain run: make verify-domain @@ -245,6 +248,7 @@ jobs: OBJECTTYPEN: ${{ steps.objecttypen.outcome }} OBJECTEN: ${{ steps.objecten.outcome }} REGISTERRECORD: ${{ steps.registerrecord.outcome }} + OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }} ACL: ${{ steps.acl.outcome }} NRC: ${{ steps.nrc.outcome }} PROJECTION: ${{ steps.projection.outcome }} @@ -266,6 +270,7 @@ jobs: echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |" echo "| Objecten API + token | $(icon "$OBJECTEN") |" echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |" + echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |" echo "| ACL ↔ OpenZaak | $(icon "$ACL") |" echo "| OpenZaak → NRC | $(icon "$NRC") |" echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |" @@ -285,7 +290,7 @@ jobs: # 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-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 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-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true - name: Tear down if: always() run: make down diff --git a/BACKLOG.md b/BACKLOG.md index b45a9de..0d0304b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -296,7 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13): Split into independently deployable sub-slices (CLAUDE.md §13): - **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028). -- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. +- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)* + - **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled. + - **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1. --- diff --git a/Makefile b/Makefile index 5b817e2..75d7163 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-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 frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-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, frontend, verify (mirrors Gitea Actions) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -201,6 +201,11 @@ verify-objecten: verify-registerrecord: bash infra/run-registerrecord-check.sh +## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an +## `objecten` notification via NRC (S-19b-1), against the already-running stack. +verify-objecten-notifications: + bash infra/run-objecten-notifications-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. @@ -212,6 +217,7 @@ verify: && bash infra/run-acl-integration.sh \ && bash infra/run-notification-check.sh \ && bash infra/run-projection-check.sh \ + && bash infra/run-objecten-notifications-check.sh \ && bash infra/run-domain-check.sh \ && bash infra/run-bff-check.sh \ && bash infra/run-e2e-check.sh || rc=$$?; \ diff --git a/infra/objecten-notifications-check.py b/infra/objecten-notifications-check.py new file mode 100644 index 0000000..3b82fee --- /dev/null +++ b/infra/objecten-notifications-check.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""S-19b-1 (#152): driver for the Objecten → NRC notification check. + +Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a +RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller +(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up, +and prints `REFERENCE ` for the caller to grep on. + +Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback. +Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery. + +Stdlib only so it runs in a bare python:3-slim container on the compose network. +""" +import base64 +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.error +import urllib.request + +OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000 +OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"] +OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000 +OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"] +NRC_BASE = os.environ["NRC_BASE"] # http://:8000 +SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://:9000/ +SINK_AUTH = os.environ["SINK_AUTH"] +CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed") +SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me") +KANAAL = "objecten" + + +def mint(): + """The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py).""" + def seg(d): + return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=") + + payload = seg({ + "iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID, + "user_id": CLIENT_ID, "user_representation": CLIENT_ID, + }) + signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload + signature = base64.urlsafe_b64encode( + hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=") + return (signing_input + b"." + signature).decode() + + +def nrc(method, url, body=None): + """Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones).""" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method, + headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) if r.length != 0 else {} + except urllib.error.HTTPError as e: + # The body carries the reason (e.g. an unregistered kanaal); the status alone does not. + raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}") + + +def token_api(base, token, method, path, body=None, crs=False): + data = json.dumps(body).encode() if body is not None else None + headers = {"Authorization": f"Token {token}"} + if body is not None: + headers["Content-Type"] = "application/json" + if crs: + headers["Accept-Crs"] = "EPSG:4326" + if body is not None: + headers["Content-Crs"] = "EPSG:4326" + req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) if r.length != 0 else {} + + +def subscribe(): + """Register an abonnement on the objecten kanaal, replacing a stale one for the same callback.""" + # NRC returns a bare list here, not a paginated envelope. + for existing in nrc("GET", "/api/v1/abonnement") or []: + if existing.get("callbackUrl") == SINK_CALLBACK: + nrc("DELETE", existing["url"]) + nrc("POST", "/api/v1/abonnement", { + "callbackUrl": SINK_CALLBACK, + "auth": SINK_AUTH, + "kanalen": [{"naam": KANAAL, "filters": {}}], + }) + print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}") + + +def objecttype_url(): + results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", []) + match = next((o for o in results if o.get("name") == "RegisterRecord"), None) + if not match: + print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr) + raise SystemExit(1) + return match["url"] + + +def main(): + subscribe() + reference = f"NOTIF-{int(time.time())}" + created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", { + "type": objecttype_url(), + "record": { + "typeVersion": 1, + "data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference}, + "startAt": time.strftime("%Y-%m-%d"), + }, + }, crs=True) + print(f">> wrote RegisterRecord {created['url']}") + print(f"REFERENCE {reference}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/run-objecten-notifications-check.sh b/infra/run-objecten-notifications-check.sh new file mode 100755 index 0000000..3b77b50 --- /dev/null +++ b/infra/run-objecten-notifications-check.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full +# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink, +# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink +# receives the notification. +# +# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat → +# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is +# connected for real, rather than merely configured. +# +# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's +# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). +# EXCEPT Objecten/Objecttypen, which must be reached by SERVICE NAME: Objecttypen echoes the request +# Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root +# (ADR-0028). +# +# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SINK_AUTH="Bearer objecten-notification-sink-token" + +cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; } +trap cleanup EXIT + +ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; } + +# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis. +obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)" +nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)" +[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; } +[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; } +net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)" +nrc_ip="$(ip "$nrc")" +echo ">> network=$net nrc=$nrc_ip" + +echo ">> starting the webhook sink" +docker rm -f rr-osink >/dev/null 2>&1 || true +sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \ + python:3-slim python /sink.py)" +docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null +docker start "$sink" >/dev/null +sleep 1 +sink_ip="$(ip rr-osink)" +echo ">> sink at $sink_ip:9000" + +echo ">> registering the abonnement + writing a RegisterRecord" +docker rm -f rr-overify >/dev/null 2>&1 || true +drv="$(docker create --network "$net" --name rr-overify \ + -e "OBJECTEN=http://objecten:8000" \ + -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ + -e "OBJECTTYPEN=http://objecttypen:8000" \ + -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ + -e "NRC_BASE=http://$nrc_ip:8000" \ + -e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \ + python:3-slim python /driver.py)" +docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null +docker start -a "$drv" +reference="$(docker logs rr-overify 2>/dev/null | sed -n 's/^REFERENCE //p' | head -1)" +docker rm -f rr-overify >/dev/null +[ -n "$reference" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; } +echo ">> wrote reference $reference" + +echo ">> waiting for the notification to reach the sink" +for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do + if docker logs rr-osink 2>&1 | grep -q "$reference"; then + echo "OK — Objecten published to NRC and the abonnement delivered it:" + docker logs rr-osink 2>&1 | grep "$reference" | tail -1 | cut -c1-500 + exit 0 + fi + sleep 2 +done + +echo "FAIL — no 'objecten' notification for $reference reached the sink." >&2 +echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2 +echo " the kanaal registration, or Objecten's notifications_config." >&2 +echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2 +echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2 +exit 1 -- 2.54.0 From 448896206f3e5e8d03c8878b57989c902acfefad Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 10:54:14 +0200 Subject: [PATCH 2/4] feat(infra): Objecten publishes register events to NRC (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the four pieces ADR-0028 deliberately left absent, and turns `NOTIFICATIONS_DISABLED` back off: - `objecten-celery`, a worker on the Objecten image (mirrors `oz-celery`), plus `CELERY_BROKER_URL`/`RESULT_BACKEND` on objecten-redis db 1 (db 0 is the cache). Without it `notifications_api_common` queues the send and nothing ever ships it. - An `nrc` service + `notifications_config` in Objecten's setup_configuration, reusing the `big-reference-seed` credential OpenZaak publishes with. - The `objecten` kanaal in NRC's setup_configuration — the name is fixed by the Objects API (`NOTIFICATIONS_KANAAL`), and publishing to an unregistered kanaal is what the failing check reported first. - `SITE_DOMAIN: objecten.local:8000` + an `objecten.local` network alias: NRC validates `hoofdObject`/`resourceUrl` with Django's URLValidator, which rejects a single-label host, so `objecten:8000` is refused with "Voer een geldige URL in." The alias keeps the dotted host resolvable so the URL still dereferences. ADR-0029 records it; ADR-0028's ceiling now points there. Makes `make verify-objecten-notifications` (dc9ca2c) pass. --- .../adr-0028-objecten-holds-the-register.md | 4 +- .../adr-0029-objecten-publishes-to-nrc.md | 107 ++++++++++++++++++ infra/docker-compose.local.yml | 37 +++++- infra/docker-compose.yml | 37 +++++- infra/objecten/setup_configuration/data.yaml | 17 +++ .../setup_configuration/data.yaml | 12 +- 6 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 docs/architecture/adr-0029-objecten-publishes-to-nrc.md diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index 8f2321b..624eaed 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -119,8 +119,8 @@ every message was dropped on the floor — a delivery path that looks wired and - ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a register write yet. -- Upgrade path: S-19b (#150) needs those notifications to source the projection from - Objecten, and turns them on together with the broker, worker, kanaal and abonnement. +- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and + notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`. ## Consequences diff --git a/docs/architecture/adr-0029-objecten-publishes-to-nrc.md b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md new file mode 100644 index 0000000..7320eca --- /dev/null +++ b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md @@ -0,0 +1,107 @@ +# ADR-0029: Objecten publishes register events to NRC + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Deciders:** Respellion engineering +- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split +- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice" + +## Context + +ADR-0028 put the authoritative register record in the Objecten API and had the ACL write +it on approval. It also switched Objecten's notifications **off** — deliberately, with a +stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so +turning the client side on alone would have produced a delivery path that looks wired and +drops every message. + +S-19b-2 (#153) wants the read projection sourced from register writes rather than +re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR +builds the four missing pieces and lifts the ceiling. + +## Decision + +**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten` +kanaal, delivered by its own Celery worker — provisioned declaratively on both sides, +exactly as ADR-0007 did for OpenZaak.** + +- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service + `nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and + `NOTIFICATIONS_DISABLED: "false"` in both compose files. +- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal + alongside `zaken`. +- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`), + mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on + `objecten-redis` db 1 (db 0 is already the cache). + +### One NRC, one credential, one kanaal per publisher + +Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its +JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that +client `heeft_alle_autorisaties` — so no second credential and no publisher-specific +authorization is needed. A second NRC, or a second credential, would buy isolation this +reference application has no use for. + +The kanaal name is **not ours to choose**: the Objects API sends +`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal +(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this +slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API +sends, so an abonnement can narrow to one objecttype instead of receiving every write. + +### `SITE_DOMAIN: objecten.local:8000` — NRC rejects single-label hosts + +NRC validates a notification's `hoofdObject`/`resourceUrl` with Django's `URLValidator`, +which refuses a **single-label** host. Objecten builds those URLs from `objects.utils.get_domain` +(i.e. `SITE_DOMAIN`), so with the compose service name they read `http://objecten:8000/...` +and every publish is refused with `"Voer een geldige URL in."` + +`SITE_DOMAIN` is therefore set to a dotted host, and the `objecten` service carries an +`objecten.local` network alias so that host still **resolves in-network** — a subscriber +that follows `resourceUrl` reaches the record it points at (which S-19b-2 will do). A +dotted name that didn't resolve would trade one broken link for a quieter one. + +This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must +match Objecten's configured `api_root`": these modules put request-derived hosts into data +that another module then validates or dereferences. + +### A worker, not a synchronous send + +`notifications_api_common` only schedules the send on transaction commit. Without a worker +the task sits in redis forever and every register write is silently undelivered — the exact +half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a +subscriber, and `nrc-beat` already drains NRC's delivery queue. + +## Verification + +`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the +CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a +throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and +asserts the notification reaches the sink. That is the whole chain in one assertion: +Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker, +worker, kanaal, notifications config — shows up as a non-delivery rather than as a green +config. + +## Consequences + +**Positive** + +- A register write is now observable by anything that subscribes, which is what S-19b-2 + (#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW. +- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured. + +**Negative / costs** + +- One more long-running container (`objecten-celery`) on an already memory-tight CI runner. +- A second publisher on the shared `big-reference-seed` credential — a credential rotation + now touches two modules. +- One more hand-kept host constant: `SITE_DOMAIN` and the `objecten.local` alias must stay + in step, in both compose files. +- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish + is visible only in the worker log. +- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or + read NRC's own delivery admin rather than building a retry layer here. + +## Coupling rules touched (CLAUDE.md §8) + +None bent. This is infrastructure between two upstream modules, over their documented +APIs; no service reaches another's database. §8.6 (idempotency at every event boundary) +applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's. diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index e35732b..8d65cfa 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -691,12 +691,21 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # S-19a: Objecten refuses every write while its Notificaties config is absent - # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → - # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn - # notifications off rather than fake a delivery path that silently drops every message. - # S-19b (#150) sources the projection from Objecten and turns this back on for real. - NOTIFICATIONS_DISABLED: "true" + # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a + # single-label host — so notifications built from `objecten:8000` are refused with + # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications + # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host + # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). + SITE_DOMAIN: objecten.local:8000 + IS_HTTPS: "no" + CELERY_BROKER_URL: redis://objecten-redis:6379/1 + CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 + # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC + # service + notifications_config are provisioned by setup_configuration + # (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends + # them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this + # off until all four pieces existed. + NOTIFICATIONS_DISABLED: "false" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: @@ -721,6 +730,22 @@ services: start_period: 30s ports: - "8021:8000" + depends_on: + objecten-init: + condition: service_completed_successfully + networks: + cg: + aliases: + - objecten.local + + # The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029). + # notifications_api_common only schedules the send on transaction commit; without a worker the + # task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery. + # No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue. + objecten-celery: + image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0} + environment: *objecten-env-local + command: /celery_worker.sh depends_on: objecten-init: condition: service_completed_successfully diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 024e879..b703ad2 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -717,12 +717,21 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # S-19a: Objecten refuses every write while its Notificaties config is absent - # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → - # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn - # notifications off rather than fake a delivery path that silently drops every message. - # S-19b (#150) sources the projection from Objecten and turns this back on for real. - NOTIFICATIONS_DISABLED: "true" + # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a + # single-label host — so notifications built from `objecten:8000` are refused with + # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications + # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host + # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). + SITE_DOMAIN: objecten.local:8000 + IS_HTTPS: "no" + CELERY_BROKER_URL: redis://objecten-redis:6379/1 + CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 + # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC + # service + notifications_config are provisioned by setup_configuration + # (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends + # them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this + # off until all four pieces existed. + NOTIFICATIONS_DISABLED: "false" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh # data.yaml is streamed into this external volume by infra/seed-config.sh before start. @@ -750,6 +759,22 @@ services: start_period: 30s ports: - "8021:8000" + depends_on: + objecten-init: + condition: service_completed_successfully + networks: + cg: + aliases: + - objecten.local + + # The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029). + # notifications_api_common only schedules the send on transaction commit; without a worker the + # task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery. + # No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue. + objecten-celery: + image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0} + environment: *objecten-env + command: /celery_worker.sh depends_on: objecten-init: condition: service_completed_successfully diff --git a/infra/objecten/setup_configuration/data.yaml b/infra/objecten/setup_configuration/data.yaml index 9f1b858..ff259c4 100644 --- a/infra/objecten/setup_configuration/data.yaml +++ b/infra/objecten/setup_configuration/data.yaml @@ -18,6 +18,16 @@ zgw_consumers: auth_type: api_key header_key: Authorization header_value: Token 0123456789abcdef0123456789abcdef01234567 + # (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and + # same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and + # authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties. + - identifier: nrc + label: Open Notificaties + api_type: nrc + api_root: http://nrc-web:8000/api/v1/ + auth_type: zgw + client_id: big-reference-seed + secret: insecure-dev-secret-change-me # (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose # objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it @@ -40,3 +50,10 @@ tokenauth: email: admin@localhost organization: Respellion is_superuser: true + +# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires +# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is +# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028). +notifications_config_enable: true +notifications_config: + notifications_api_service_identifier: nrc diff --git a/infra/opennotificaties/setup_configuration/data.yaml b/infra/opennotificaties/setup_configuration/data.yaml index 18abd80..e1557c0 100644 --- a/infra/opennotificaties/setup_configuration/data.yaml +++ b/infra/opennotificaties/setup_configuration/data.yaml @@ -29,7 +29,9 @@ autorisaties_api_config_enable: true autorisaties_api: authorizations_api_service_identifier: openzaak-ac -# 4. The kanaal OpenZaak publishes zaak events on. +# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1). +# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants +# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten. notifications_kanalen_config_enable: true notifications_kanalen_config: items: @@ -39,3 +41,11 @@ notifications_kanalen_config: - bronorganisatie - zaaktype - vertrouwelijkheidaanduiding + # 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is + # fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The + # filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by + # objecttype rather than receiving every object write in the register. + - naam: objecten + documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/ + filters: + - object_type -- 2.54.0 From d76abf2df2c9fde28d4e8b18faf49f2f11effe62 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 11:31:31 +0200 Subject: [PATCH 3/4] fix(infra): address Objecten by a dotted host so NRC accepts its notifications (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker published and NRC answered 400 on every message: {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} NRC types both as DRF `URLField`, and Django's URLValidator refuses a single-label host. Objecten fills them from the object `url` DRF built with `request.build_absolute_uri` — the Host the *caller* used — so `SITE_DOMAIN` never entered into it. Dropped that env pair; it was a wrong guess at the mechanism. The fix is on the caller side: keep the `objecten.local` network alias and point every writer whose writes must be notified at it — the ACL, the gateway integration tests, and this slice's verify driver. Readers keep the plain service name. ADR-0029 updated with the real mechanism and the ceiling it leaves: a new writer using `objecten:8000` gets a 201 and silently no notification. --- .../adr-0029-objecten-publishes-to-nrc.md | 39 +++++++++++++------ infra/docker-compose.local.yml | 16 ++++---- infra/docker-compose.yml | 16 ++++---- infra/run-objecten-notifications-check.sh | 9 +++-- .../ObjectenGatewayIntegrationTests.cs | 4 +- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/docs/architecture/adr-0029-objecten-publishes-to-nrc.md b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md index 7320eca..22a4e5f 100644 --- a/docs/architecture/adr-0029-objecten-publishes-to-nrc.md +++ b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md @@ -47,21 +47,36 @@ The kanaal name is **not ours to choose**: the Objects API sends slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API sends, so an abonnement can narrow to one objecttype instead of receiving every write. -### `SITE_DOMAIN: objecten.local:8000` — NRC rejects single-label hosts +### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts -NRC validates a notification's `hoofdObject`/`resourceUrl` with Django's `URLValidator`, -which refuses a **single-label** host. Objecten builds those URLs from `objects.utils.get_domain` -(i.e. `SITE_DOMAIN`), so with the compose service name they read `http://objecten:8000/...` -and every publish is refused with `"Voer een geldige URL in."` +NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's +`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both +from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the +caller used**. Write to `http://objecten:8000` and NRC answers every publish with -`SITE_DOMAIN` is therefore set to a dotted host, and the `objecten` service carries an -`objecten.local` network alias so that host still **resolves in-network** — a subscriber -that follows `resourceUrl` reaches the record it points at (which S-19b-2 will do). A -dotted name that didn't resolve would trade one broken link for a quieter one. +``` +{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} +``` + +which `objecten-celery` then retries with exponential backoff, forever, in the background — +the write itself having returned 201. + +`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the +caller side: the `objecten` service carries an `objecten.local` network alias, and every +component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway +integration tests, this slice's verify check — addresses it there. An alias rather than a +plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that +follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are +unaffected and keep using the plain service name. This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must match Objecten's configured `api_root`": these modules put request-derived hosts into data -that another module then validates or dereferences. +another module then validates or dereferences. + +- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201 + and silently no notification. +- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local` + so the plain name stops working, rather than adding a lint. ### A worker, not a synchronous send @@ -93,8 +108,8 @@ config. - One more long-running container (`objecten-celery`) on an already memory-tight CI runner. - A second publisher on the shared `big-reference-seed` credential — a credential rotation now touches two modules. -- One more hand-kept host constant: `SITE_DOMAIN` and the `objecten.local` alias must stay - in step, in both compose files. +- Objecten now has two in-network names, and which one a caller uses silently decides + whether its writes are notified (ceiling above). - ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish is visible only in the worker log. - Upgrade path: if undelivered register events start mattering, subscribe an audit sink or diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index 8d65cfa..b4c83cf 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -341,7 +341,8 @@ services: # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves # it by name — lazily, on the first approval, so no depends_on is needed here. - Acl__Objecten__BaseUrl: http://objecten:8000/ + # Dotted host on purpose — see the `objecten.local` alias below (ADR-0029). + Acl__Objecten__BaseUrl: http://objecten.local:8000/ Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} @@ -691,13 +692,6 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a - # single-label host — so notifications built from `objecten:8000` are refused with - # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications - # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host - # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). - SITE_DOMAIN: objecten.local:8000 - IS_HTTPS: "no" CELERY_BROKER_URL: redis://objecten-redis:6379/1 CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC @@ -735,6 +729,12 @@ services: condition: service_completed_successfully networks: cg: + # Objecten reflects the *request* Host into the `url` it returns, and + # notifications_api_common publishes that url as the notification's hoofdObject / + # resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a + # single-label host ("Voer een geldige URL in."). So every caller whose writes must be + # notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029). + # Reads are unaffected and still use the plain service name. aliases: - objecten.local diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index b703ad2..12836c9 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -326,7 +326,8 @@ services: # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves # it by name — lazily, on the first approval, so no depends_on is needed here. - Acl__Objecten__BaseUrl: http://objecten:8000/ + # Dotted host on purpose — see the `objecten.local` alias below (ADR-0029). + Acl__Objecten__BaseUrl: http://objecten.local:8000/ Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} @@ -717,13 +718,6 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a - # single-label host — so notifications built from `objecten:8000` are refused with - # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications - # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host - # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). - SITE_DOMAIN: objecten.local:8000 - IS_HTTPS: "no" CELERY_BROKER_URL: redis://objecten-redis:6379/1 CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC @@ -764,6 +758,12 @@ services: condition: service_completed_successfully networks: cg: + # Objecten reflects the *request* Host into the `url` it returns, and + # notifications_api_common publishes that url as the notification's hoofdObject / + # resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a + # single-label host ("Voer een geldige URL in."). So every caller whose writes must be + # notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029). + # Reads are unaffected and still use the plain service name. aliases: - objecten.local diff --git a/infra/run-objecten-notifications-check.sh b/infra/run-objecten-notifications-check.sh index 3b77b50..1b3cff1 100755 --- a/infra/run-objecten-notifications-check.sh +++ b/infra/run-objecten-notifications-check.sh @@ -11,9 +11,10 @@ # # All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's # callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). -# EXCEPT Objecten/Objecttypen, which must be reached by SERVICE NAME: Objecttypen echoes the request -# Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root -# (ADR-0028). +# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the +# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028); +# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the +# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029). # # Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates. set -euo pipefail @@ -48,7 +49,7 @@ echo ">> sink at $sink_ip:9000" echo ">> registering the abonnement + writing a RegisterRecord" docker rm -f rr-overify >/dev/null 2>&1 || true drv="$(docker create --network "$net" --name rr-overify \ - -e "OBJECTEN=http://objecten:8000" \ + -e "OBJECTEN=http://objecten.local:8000" \ -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ -e "OBJECTTYPEN=http://objecttypen:8000" \ -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs index 6ded7c1..14eff91 100644 --- a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs @@ -20,7 +20,7 @@ public sealed class ObjectenGatewayIntegrationTests new HttpClient(), new ObjectenOptions { - BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), + BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")), Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), @@ -65,7 +65,7 @@ public sealed class ObjectenGatewayIntegrationTests { using var http = new HttpClient(); var objecttype = await ResolveObjecttypeUrlAsync(http); - var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")), + var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten.local:8000")), "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); -- 2.54.0 From a5fd47e546407dfe23854ee239e002c3dfe9ba35 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 11:50:07 +0200 Subject: [PATCH 4/4] fix(infra): correlate the delivery on the object URL, not the record reference (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish chain works — the sink received it: {"kanaal": "objecten", "resource": "object", "kenmerken": {"objectType": "…"}, "hoofdObject": "http://objecten.local:8000/api/v2/objects/a68d4c46-…", …} The check just looked for the wrong thing. An NRC notification carries hoofdObject / resourceUrl and kenmerken — never the record data — so the `reference` inside the RegisterRecord it wrote was never going to appear in the delivered message. Grep the sink for the object URL instead, which is what identifies the write. --- infra/objecten-notifications-check.py | 6 ++++-- infra/run-objecten-notifications-check.sh | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/infra/objecten-notifications-check.py b/infra/objecten-notifications-check.py index 3b82fee..dab9f71 100644 --- a/infra/objecten-notifications-check.py +++ b/infra/objecten-notifications-check.py @@ -4,7 +4,7 @@ Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller (run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up, -and prints `REFERENCE ` for the caller to grep on. +and prints `OBJECT_URL ` for the caller to grep on. Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback. Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery. @@ -111,7 +111,9 @@ def main(): }, }, crs=True) print(f">> wrote RegisterRecord {created['url']}") - print(f"REFERENCE {reference}") + # An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object + # URL, not the reference in its data, is what the caller can correlate the delivery on. + print(f"OBJECT_URL {created['url']}") return 0 diff --git a/infra/run-objecten-notifications-check.sh b/infra/run-objecten-notifications-check.sh index 1b3cff1..f6cfabf 100755 --- a/infra/run-objecten-notifications-check.sh +++ b/infra/run-objecten-notifications-check.sh @@ -58,22 +58,24 @@ drv="$(docker create --network "$net" --name rr-overify \ python:3-slim python /driver.py)" docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null docker start -a "$drv" -reference="$(docker logs rr-overify 2>/dev/null | sed -n 's/^REFERENCE //p' | head -1)" +object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)" docker rm -f rr-overify >/dev/null -[ -n "$reference" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; } -echo ">> wrote reference $reference" +[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; } +echo ">> wrote $object_url" +# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record +# data, so the reference inside the record is not in the delivered message. echo ">> waiting for the notification to reach the sink" for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do - if docker logs rr-osink 2>&1 | grep -q "$reference"; then + if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then echo "OK — Objecten published to NRC and the abonnement delivered it:" - docker logs rr-osink 2>&1 | grep "$reference" | tail -1 | cut -c1-500 + docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500 exit 0 fi sleep 2 done -echo "FAIL — no 'objecten' notification for $reference reached the sink." >&2 +echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2 echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2 echo " the kanaal registration, or Objecten's notifications_config." >&2 echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2 -- 2.54.0