feat(infra): wire OpenZaak → Open Notificaties notifications (closes #56) #57
@@ -80,6 +80,22 @@ jobs:
|
||||
if: failure()
|
||||
run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true
|
||||
|
||||
notifications:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# `make verify-notifications` brings up OpenZaak + NRC, seeds, and asserts a
|
||||
# zaak-create notification is delivered to a subscriber — all via containers on
|
||||
# the compose network (the runner can't reach published ports; §5). Needs only
|
||||
# Docker + egress (base images, selectielijst.openzaak.nl).
|
||||
- run: make verify-notifications
|
||||
- name: dump OpenZaak/NRC logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat 2>&1 || true
|
||||
- name: tear down on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml down --volumes 2>&1 || true
|
||||
|
||||
compose-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
39
infra/notification-sink.py
Executable file
39
infra/notification-sink.py
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A throwaway webhook sink for verifying the OpenZaak → NRC notification path.
|
||||
|
||||
NRC delivers abonnement callbacks here as POSTs; each body is printed to stdout
|
||||
(prefixed `NOTIFICATION `) so the verify harness can assert on `docker logs`.
|
||||
|
||||
NRC refuses to register an abonnement whose callback is unauthenticated
|
||||
(`no-auth-on-callback`): when validating it sends a probe and expects the callback
|
||||
to reject a request without the configured `Authorization` value. So this sink
|
||||
enforces that header (EXPECTED_AUTH env) — 401 without it, 204 with it.
|
||||
|
||||
Stdlib only. Listens on :9000. See infra/verify-notifications.sh / S-01-c (#56).
|
||||
"""
|
||||
import http.server
|
||||
import os
|
||||
import sys
|
||||
|
||||
EXPECTED_AUTH = os.environ.get("EXPECTED_AUTH", "Bearer notification-sink-token")
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length", 0))
|
||||
body = self.rfile.read(length).decode("utf-8", "replace")
|
||||
if self.headers.get("Authorization") != EXPECTED_AUTH:
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
return
|
||||
print("NOTIFICATION " + body, flush=True)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args): # silence default request logging
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
http.server.HTTPServer(("0.0.0.0", 9000), Handler).serve_forever()
|
||||
sys.exit(0)
|
||||
90
infra/verify-notification-driver.py
Executable file
90
infra/verify-notification-driver.py
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the OpenZaak → NRC notification check from *inside* the compose network.
|
||||
|
||||
Registers an abonnement on the `zaken` kanaal pointing at a webhook sink, then
|
||||
creates a zaak against the published BIG zaaktype. OpenZaak publishes a
|
||||
`zaken`/`create` notification; NRC delivers it to the sink. The host harness
|
||||
(infra/verify-notifications.sh) then asserts the sink received it.
|
||||
|
||||
Reached by container IP, not service name: OpenZaak/NRC validate URLs with Django's
|
||||
URLValidator, which rejects a single-label host like `openzaak`. Stdlib only.
|
||||
Env: OZ_BASE, NRC_BASE, SINK_CALLBACK, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OZ = os.environ["OZ_BASE"].rstrip("/")
|
||||
NRC = os.environ["NRC_BASE"].rstrip("/")
|
||||
SINK_CALLBACK = os.environ["SINK_CALLBACK"]
|
||||
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer notification-sink-token")
|
||||
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||
RSIN = "517439943"
|
||||
|
||||
|
||||
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": "verify", "user_representation": "verify"},
|
||||
separators=(",", ":")).encode())
|
||||
)
|
||||
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||
|
||||
|
||||
def call(method, url, body=None, crs=False):
|
||||
headers = {"Authorization": "Bearer " + token(),
|
||||
"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if crs:
|
||||
headers["Accept-Crs"] = "EPSG:4326"
|
||||
headers["Content-Crs"] = "EPSG:4326"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=data, method=method, headers=headers), timeout=30
|
||||
) as r:
|
||||
return r.status, json.loads(r.read() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or "null")
|
||||
|
||||
|
||||
def main():
|
||||
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||
"callbackUrl": SINK_CALLBACK,
|
||||
"auth": SINK_AUTH,
|
||||
"kanalen": [{"naam": "zaken", "filters": {}}],
|
||||
})
|
||||
if status != 201:
|
||||
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||
print(f"abonnement: {ab['url']}")
|
||||
|
||||
status, body = call(
|
||||
"GET", f"{OZ}/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief")
|
||||
results = body.get("results", []) if status == 200 else []
|
||||
if not results:
|
||||
sys.exit("no published BIG-REGISTRATIE zaaktype — seed with OZ_PUBLISH=1 first")
|
||||
zaaktype = results[0]["url"]
|
||||
|
||||
status, zaak = call("POST", f"{OZ}/zaken/api/v1/zaken", {
|
||||
"bronorganisatie": RSIN, "verantwoordelijkeOrganisatie": RSIN,
|
||||
"zaaktype": zaaktype, "startdatum": time.strftime("%Y-%m-%d"),
|
||||
"vertrouwelijkheidaanduiding": "openbaar",
|
||||
}, crs=True)
|
||||
if status != 201:
|
||||
sys.exit(f"create zaak -> {status}: {json.dumps(zaak)}")
|
||||
# The harness greps the sink for this exact URL.
|
||||
print(f"ZAAK_CREATED {zaak['url']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
93
infra/verify-notifications.sh
Executable file
93
infra/verify-notifications.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify the OpenZaak → Open Notificaties (NRC) notification path end to end (S-01-c,
|
||||
# #56): a zaak created in OpenZaak is published to NRC and delivered to a subscriber.
|
||||
#
|
||||
# Everything that talks to OpenZaak/NRC runs *inside* the compose network and reaches
|
||||
# them by container IP — the hosted CI runner can't reach published ports (sibling
|
||||
# containers, gitea-actions-gotchas.md §5) and OpenZaak/NRC reject a single-label host
|
||||
# in URLs (Django URLValidator). Plain docker primitives only (docker/podman-portable),
|
||||
# like infra/seed-config.sh and infra/run-integration.sh. See ADR-0007.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
|
||||
NRC_COMPOSE="$here/opennotificaties/docker-compose.yml"
|
||||
SINK_AUTH="Bearer notification-sink-token"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true
|
||||
docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" down --volumes >/dev/null 2>&1 || true
|
||||
docker volume rm -f rr-oz-config rr-nrc-config >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
wait_healthy() { # name-regex -> echoes container id
|
||||
local re="$1" cid status
|
||||
for _ in $(seq 1 140); do
|
||||
cid="$(docker ps -q --filter "name=$re" | head -1)"
|
||||
if [ -n "$cid" ]; then
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)"
|
||||
[ "$status" = healthy ] && { echo "$cid"; return 0; }
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ">> bringing up OpenZaak + Open Notificaties (notifications enabled)"
|
||||
bash "$here/seed-config.sh" oz nrc
|
||||
OZ_NOTIFICATIONS_DISABLED=false docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" up -d
|
||||
|
||||
echo ">> waiting for OpenZaak + NRC to be healthy"
|
||||
oz="$(wait_healthy 'openzaak[-_]openzaak')" || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; }
|
||||
nrc="$(wait_healthy 'nrc-web')" || { echo "ERROR: NRC not healthy" >&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")"
|
||||
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype"
|
||||
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 ">> starting the webhook sink"
|
||||
docker rm -f rr-nsink >/dev/null 2>&1 || true
|
||||
sink="$(docker create --network "$net" --name rr-nsink -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-nsink)"
|
||||
echo ">> sink at $sink_ip:9000"
|
||||
|
||||
echo ">> registering abonnement + creating a zaak"
|
||||
docker rm -f rr-nverify >/dev/null 2>&1 || true
|
||||
drv="$(docker create --network "$net" --name rr-nverify \
|
||||
-e "OZ_BASE=http://$oz_ip:8000" -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/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
|
||||
docker start -a "$drv"
|
||||
zaak_url="$(docker logs rr-nverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
|
||||
docker rm -f rr-nverify >/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 ">> waiting for the notification to reach the sink"
|
||||
for _ in $(seq 1 30); do
|
||||
if docker logs rr-nsink 2>&1 | grep -q "$zaak_uuid"; then
|
||||
echo "OK — NRC delivered the zaken notification for zaak $zaak_uuid to the sink"
|
||||
docker logs rr-nsink 2>&1 | grep "$zaak_uuid" | tail -1 | cut -c1-300
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "FAIL — the sink never received a notification for zaak $zaak_uuid" >&2
|
||||
echo "--- sink log ---" >&2; docker logs rr-nsink 2>&1 | tail -8 >&2
|
||||
exit 1
|
||||
Reference in New Issue
Block a user