fix(infra): address Objecten by a dotted host so NRC accepts its notifications (refs #152)
CI / build (pull_request) Successful in 1m9s
CI / lint (pull_request) Successful in 1m24s
CI / unit (pull_request) Successful in 1m32s
CI / frontend (pull_request) Successful in 3m13s
CI / mutation (pull_request) Successful in 6m25s
CI / verify-stack (pull_request) Failing after 8m11s

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.
This commit is contained in:
not
2026-08-28 11:31:31 +02:00
parent 448896206f
commit d76abf2df2
5 changed files with 50 additions and 34 deletions
@@ -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
+8 -8
View File
@@ -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
+8 -8
View File
@@ -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
+5 -4
View File
@@ -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}" \
@@ -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));