Compare commits

..
Author SHA1 Message Date
notandClaude Opus 5.5 7baa5d2006 docs(runbook): stale "out-of-date" PR after a stacked PR is retargeted (refs #195)
CI / lint (pull_request) Successful in 1m43s
CI / k8s (pull_request) Successful in 9s
CI / build (pull_request) Successful in 1m8s
CI / docs (pull_request) Successful in 44s
CI / unit (pull_request) Successful in 1m21s
CI / frontend (pull_request) Successful in 2m20s
CI / mutation (pull_request) Successful in 4m28s
CI / verify-stack (pull_request) Skipped
Gitea 1.27 blocks the merge on a stored commits_behind that a same-second
retarget + force-push can leave stale, while Update branch recomputes it
live and refuses. Record the symptom, the cause and the amend + force-push fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 12:56:13 +02:00
notandClaude Opus 5.5 5fdcbd27c0 build(infra): run ClamAV in compose and on the cluster (closes #191) (#193)
Deploy to Talos / deploy (push) Successful in 2m40s
CI / k8s (push) Successful in 1m23s
CI / build (push) Successful in 4m55s
CI / lint (push) Successful in 6m25s
CI / unit (push) Successful in 1m6s
CI / docs (push) Successful in 1m22s
CI / frontend (push) Successful in 2m44s
CI / mutation (push) Successful in 4m22s
CI / verify-stack (push) Successful in 21m56s
Runs a ClamAV daemon (clamav/clamav:1.4.6) in both compose stacks and the Helm chart, health-gated, with a verify-clamav check (EICAR found, clean OK) in verify-stack. ADR-0036 records the scan-in-domain, fail-closed decision (#190).

closes #191

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:53:16 +00:00
not eba1381ef2 feat(k8s): make the OTLP trace endpoint a chart value (closes #186) (#187)
CI / k8s (push) Successful in 12s
CI / lint (push) Successful in 1m50s
CI / build (push) Successful in 1m25s
CI / docs (push) Successful in 52s
CI / unit (push) Successful in 1m36s
CI / frontend (push) Successful in 2m27s
Deploy to Talos / deploy (push) Successful in 2m31s
CI / mutation (push) Successful in 5m1s
CI / verify-stack (push) Successful in 10m31s
closes #186

- New `otelEndpoint` value (default `http://tempo:4317`, unchanged behaviour), rendered into the `otel` env group via `tpl`.
- `deploy.yaml` passes `--set otelEndpoint=$OTEL_ENDPOINT` only when the repo variable is set.

Verified: `helm template` renders the default, and with `--set otelEndpoint=http://tempo.monitoring.svc:4317` it renders that override. No new dependency, no ADR needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #187
2026-09-28 13:47:47 +00:00
not b444e0c680 ci(docs): build the MkDocs site with --strict in CI (refs #173) (#189)
CI / k8s (push) Successful in 10s
CI / build (push) Successful in 1m22s
CI / lint (push) Successful in 2m5s
CI / docs (push) Successful in 1m1s
CI / unit (push) Successful in 1m29s
CI / frontend (push) Successful in 2m31s
Deploy to Talos / deploy (push) Successful in 2m36s
CI / mutation (push) Successful in 5m9s
CI / verify-stack (push) Canceled after 8m47s
refs #173. This is the minimum step from the issue: the site is now **built** in CI, not **published**. Publishing still needs an ADR (Gitea has no built-in Pages) or a CLAUDE.md §12 correction, so the issue stays open.

- `make docs`: creates a throwaway `.venv-docs`, installs pinned `mkdocs==1.6.1` and `mkdocs-material==9.7.7`, then runs `mkdocs build --strict`. The target is also added to `make ci`.
- New `docs` job in `ci.yaml` (`setup-python@v5`, then `make docs`).
- Red, then green: the first commit fails on a link from `runbooks/ci.md` to a file outside `docs/`; the second turns that link into plain code.

**Dependency (§13):** mkdocs and mkdocs-material were already the site's declared toolchain (`mkdocs.yml`) but were never installed anywhere. They give a strict link/nav/theme check. Replacing them means writing our own Markdown link checker, and `check-docs-nav.py` already covers only the nav half. Risk: Material warns that MkDocs 2.0 drops its plugin/theme system, so both are pinned exactly. No ADR, since this adds no new decision beyond what `mkdocs.yml` already assumes.

Verified locally: `make docs` → `Documentation built in 0.87 seconds`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #189
2026-09-28 13:25:40 +00:00
not 733ba71173 fix(acl): keep the integration tests out of Stryker's solution (closes #174) (#188)
CI / k8s (push) Successful in 10s
CI / lint (push) Successful in 1m53s
CI / build (push) Successful in 1m22s
CI / unit (push) Successful in 1m35s
CI / frontend (push) Successful in 2m46s
Deploy to Talos / deploy (push) Successful in 2m37s
CI / mutation (push) Successful in 4m56s
CI / verify-stack (push) Canceled after 9m56s
closes #174

Took fix option 3, cut down: removed `Acl.IntegrationTests` from `services/acl/Acl.slnx`. Only Stryker reads that file. `make build`, `make lint` and `make unit` use the root `register-referentie.slnx`, and `Dockerfile.integration` targets the csproj directly, so nothing else changes. A comment in the slnx and a note in `docs/runbooks/ci.md` explain why the project is left out.

**Verified locally** (`cd services/acl && dotnet stryker`):
- `Number of tests found: 86` (was 94); the `8 tests are failing` warning is gone.
- Final score **90.45 %**, the same as before. Tests that fail their initial run were never used to kill mutants, so the number was not depressed, only unverified. Re-baselined from this clean run: `break: 90` stays (§5, never lower).

No test-first commit: this changes build config only. The check is the Stryker initial-run log above.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #188
2026-09-28 12:56:10 +00:00
not 1489f68796 docs(arch): ADR-0035 — publish the stack through the existing labs Caddy (closes #177) (#185)
CI / k8s (push) Successful in 8s
CI / build (push) Successful in 1m43s
CI / lint (push) Successful in 2m1s
CI / unit (push) Successful in 1m41s
CI / frontend (push) Successful in 2m28s
Deploy to Talos / deploy (push) Successful in 2m26s
CI / mutation (push) Successful in 4m58s
CI / verify-stack (push) Canceled after 7m18s
## What & why

ADR-0035 records the decision issue #177 asked for, which went the other way from its proposal. The stack is published through the **existing labs Caddy** over a reverse SSH tunnel, not through an in-cluster Caddy edge. The deciding facts: the Talos hypervisor sits behind office NAT with no inbound path, and the labs Caddy already holds 80/443 and the `*.labs.respellion.tech` wildcard certificate.

The ADR covers the chain (Caddy → `openssh-server` → tunnel → NodePorts), `keycloakUrl` / `big.keycloakUrl`, `KC_PROXY_HEADERS`, the optional demo OTP autofill, the alternatives (including the closed PR #178), and the costs: routing outside the cluster, two SSH hops, a single issuer string, public demo portals, and 401s after a Keycloak restart.

- `docs/architecture/adr-0035-public-access-through-the-labs-caddy.md` (new)
- `mkdocs.yml`: nav entry (`check-docs-nav.py` passes)
- `docs/runbooks/kubernetes-talos.md`: links the ADR from "Publishing through the labs Caddy"

Closes #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Conventional Commit referencing the issue.
- [ ] CI green
- [x] ADR added in `docs/architecture/`.

## Notes for reviewers

- The number 0035 was used in the unmerged #178 for the in-cluster ADR. That ADR never reached `main`, so the number is free there.
- Implementation PRs: #179, #180, #181. Related CI fixes: #183, #184.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #185
2026-09-28 12:30:11 +00:00
not f4b41aca84 ci: run verify-stack only on push to main, not on PRs (refs #182) (#184)
CI / k8s (push) Successful in 8s
CI / build (push) Successful in 1m40s
CI / lint (push) Successful in 1m53s
CI / unit (push) Successful in 1m46s
CI / frontend (push) Successful in 2m35s
Deploy to Talos / deploy (push) Successful in 2m27s
CI / mutation (push) Successful in 4m52s
CI / verify-stack (push) Successful in 12m31s
## What & why

`verify-stack` now runs only on a push to `main` (a merge), not on pull requests. Every job before it (lint, k8s, build, unit, frontend, mutation) is unchanged and still runs on PRs.

**Why:** the Gitea runner shares the 15 GB lab node with the deployed stack. `verify-stack` boots the whole stack a second time inside `dind`, which is what got the runner OOM-killed (#182). Running it once per merge instead of on every PR push roughly halves how often that happens.

- `.gitea/workflows/ci.yaml`: `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` on `verify-stack`, with a comment.
- `docs/runbooks/ci.md`: the job table notes "push to main only".

Refs #182

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test first. *(CI config.)*
- [x] Conventional Commits referencing the issue.
- [ ] CI green. **This PR's own run should show `verify-stack` as skipped**, which is the check for the PR half.
- [x] Docs updated (`docs/runbooks/ci.md`).

## Notes for reviewers

- **gotchas §7:** on Gitea 1.27 + act_runner 2.0.0, a `needs` job gated by a *status-function* `if` (`always()`/`cancelled()`) never leaves `waiting`. This `if` is a plain event check, so it keeps the implicit `success()` and should not hit that path. It's only proven once the first merge to `main` runs `verify-stack`. If that run sits in `waiting` with no logs, force-cancel it and revert this.
- **Policy change:** CLAUDE.md §3/§15 say the compose-up smoke test "runs in CI and gates merges". After this it runs *after* the merge, so a live-stack break shows up as a red `main` (P0 per §15) instead of a blocked PR. CLAUDE.md changes need their own issue and PR, so I left it untouched. It should be updated if this approach is kept.
- If `verify-stack` is a required status check in branch protection, remove it there too. Otherwise PRs will wait for a check that never runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #184
2026-09-25 13:00:26 +00:00
not 5494363221 fix(k8s): keep Keycloak's backchannel URLs https behind the labs Caddy (refs #177) (#180)
CI / k8s (push) Successful in 8s
CI / lint (push) Successful in 5m15s
CI / build (push) Successful in 5m6s
CI / unit (push) Successful in 1m28s
CI / frontend (push) Successful in 3m17s
Deploy to Talos / deploy (push) Successful in 2m58s
CI / mutation (push) Successful in 5m2s
CI / verify-stack (push) Successful in 10m35s
## What & why

Follow-up to #179. After login through `https://big-mijn.labs.respellion.tech`, the browser blocked the token request as mixed content. `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` makes Keycloak build its token, userinfo and certs URLs from the incoming request. Behind the labs Caddy that request arrives as plain `http`, so the discovery document listed `http://big-auth…/token`.

`KC_PROXY_HEADERS=xforwarded` makes Keycloak trust the `X-Forwarded-Proto: https` that Caddy sends. In-cluster calls (the BFF → `keycloak:8080`) carry no such header, so they are unchanged, and so is the localhost/NodePort setup.

Refs #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(One env var, verified live instead.)*
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green
- [x] Docs updated if behaviour, contracts, or operations changed. *(Comment in values.yaml.)*

## Notes for reviewers

- I already applied this to the running cluster with `kubectl set env` and checked it. Both realms' discovery documents now have 0 `http://` URLs, and a `jan-burger` token from the public Keycloak still gets a 204 from the BFF. Merging keeps the next deploy from reverting it.
- Keycloak now trusts `X-Forwarded-*` from anything that can reach it. Its only entry points are in-cluster callers and the NodePort, which the reverse tunnel exposes only to Caddy. `KC_PROXY_TRUSTED_ADDRESSES` could narrow that if the NodePort is ever exposed more widely.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #180
2026-09-25 12:30:15 +00:00
not 0074a1bff3 feat(k8s): optionally auto-fill the medewerker OTP step for the public demo (refs #177) (#181)
CI / k8s (push) Successful in 8s
CI / build (push) Successful in 1m38s
CI / lint (push) Successful in 1m55s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / frontend (push) Canceled after 12s
CI / unit (push) Canceled after 18s
Deploy to Talos / deploy (push) Successful in 2m30s
## What & why

For the public demo on `big-behandel` / `big-beheer`, visitors should see MFA being enforced without needing an authenticator app. This adds an opt-in Keycloak theme that fills in and submits the medewerker OTP code itself.

- **Theme as real files in `infra/keycloak/themes/big-demo/`**, next to the realms:
  - `login/theme.properties`: `keycloak.v2` plus `scripts=js/otp-autofill.js`. I checked the 26.1 source: `keycloak.v2` loads theme `scripts` and sets none of its own.
  - `login/resources/js/otp-autofill.js`: on the OTP page, computes the code (RFC 6238, Keycloak's default policy) from the fixture secret `BIGMEDEWERKEROTPSEED` and submits it.
  - `account`, `admin`, `email`: plain children of Keycloak 26's defaults. Without them the account console returns 500 (see notes).
- **Seeded like every other file input:** `infra/helm/seed-configmaps.sh` creates the `rr-kc-theme` ConfigMap, and the chart mounts it as a directory. The podspec gains `items` so flat ConfigMap keys map to theme paths. Keycloak runs `start-dev` (no theme cache), so edits show up about a minute after a reseed.
- **Switch:** `demo.otpAutofill` only decides whether `KC_SPI_THEME_DEFAULT=big-demo` is set. `big.env` now skips env values that render empty, and no existing env var is empty. **Off, the render is identical to main except for that one missing variable,** so Keycloak keeps its stock theme. The realm JSONs are untouched, so compose and the e2e tests still require a code.
- **Single-use codes:** a second login in the same 30 s window spends the next counter, as `nextUnusedCounter` does in the e2e. Past that it only fills in the field and doesn't submit, so a rejected code can't loop.
- **Deploy workflow:** repo variable `OTP_AUTOFILL=true` → `--set demo.otpAutofill=true`. Flipping it changes the pod's env, so Keycloak restarts.

Refs #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Not done; checks below.)*
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green
- [x] `docker compose up` unaffected (chart only).
- [x] Docs updated (Talos runbook, "Publishing through the labs Caddy").
- [ ] ADR. The fixture-secret trade-off is ADR-0031's; this only automates typing it in.

## Notes for reviewers

- **Tested on the live cluster.** I patched the running Keycloak with the rendered theme (autofill on) and ran real headless Chromium logins against the public hosts:
  - `merel-behandelaar` on big-behandel: only username and password typed. The OTP page loaded the script, submitted by itself, and the user landed in the Werkbak.
  - `jan-burger` on big-mijn still logs in (regression check).
  - `/realms/medewerker/account/` returns 200.
- **Account console 500, found live and fixed in the second commit.** `KC_SPI_THEME_DEFAULT` applies to every theme type, and Keycloak does *not* fall back for a type the theme lacks (`NullPointerException ... "theme" is null`). `big-demo` now declares login, account, admin and email, each a plain child of Keycloak 26's default. It's one ConfigMap mounted as a directory; the podspec gains `items` for that.
- **Keycloak restarts cause about 5 minutes of BFF 401s.** This is not caused by this PR, but you'll see it whenever Keycloak restarts. Dev-mode Keycloak makes new signing keys on each boot, and the BFF refreshes its cached keys at most every 5 minutes. Seen live: 401 right after the restart, 204 about 4½ minutes later. Flipping `OTP_AUTOFILL` restarts Keycloak, so expect this briefly.
- `make k8s-lint` and `make k8s-drift` pass. The rendered script's code matches `infra/keycloak/check_realms.py otp`.
- **Security:** with it on, the public behandel and beheer portals are protected only by the committed password `test123`. That's intentional for synthetic demo data. Never enable it anywhere real.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #181
2026-09-25 11:34:30 +00:00
not 594fdde227 fix(infra): cap celery workers at 2 so the shared node stops OOM-killing CI (closes #182) (#183)
CI / k8s (push) Successful in 9s
CI / build (push) Successful in 1m39s
CI / lint (push) Successful in 2m0s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / unit (push) Canceled after 1m9s
CI / frontend (push) Canceled after 1m16s
Deploy to Talos / deploy (push) Successful in 2m31s
## What & why

`verify-stack` is being killed by the OOM controller on the shared Talos node, on main (run 827) and on #180 (run 830). The cause is Celery: with `CELERY_WORKER_CONCURRENCY` unset, `oz-celery` and `nrc-celery` each fork one worker per CPU. That's 22 each on the lab node, 49 Celery processes at about 225 MB apiece. Details and the kernel log evidence are in #182.

This sets `CELERY_WORKER_CONCURRENCY: "2"` in the oz and nrc env groups:
- **compose** (`&oz-env`, `&nrc-env`): what `verify-stack` starts inside `dind`.
- **chart** (`envGroups.oz` / `.nrc`): the deployed demo on the same node.

Both images' `/celery_worker.sh` honour the variable; I checked in the running pods. Web, init and beat containers share the anchors and ignore it. `objecten-celery` already defaults to 1.

Closes #182

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Resource setting; the evidence is the OOM log in #182.)*
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green. This PR's `verify-stack` run is the check.
- [x] `docker compose config` renders the variable into all 7 services on the two anchors.
- [x] Docs: comments next to the setting, following the existing uWSGI notes.

## Notes for reviewers

- `make k8s-lint` and `make k8s-drift` pass.
- **Not applied live.** I couldn't patch the running cluster from my session. After merge, the deploy updates the `oz-env` / `nrc-env` ConfigMaps. The celery pods only pick that up on restart, and the deploy step restarts only this repo's nine services. So run once:
  `kubectl -n big rollout restart deploy/oz-celery deploy/nrc-celery`
- **Why 2 and not 1:** this matches `UWSGI_THREADS: "2"`, and it keeps one notification delivery from blocking behind a slow task. It cuts roughly 40 processes, about 9 GB RSS (less in practice, because forked workers share pages).
- **Longer term:** CI and the demo share one 15 GB VM. Resource requests on the runner, or moving the runner off the node, would stop one from starving the other.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #183
2026-09-25 11:11:45 +00:00
23 changed files with 503 additions and 14 deletions
+23 -1
View File
@@ -98,6 +98,17 @@ jobs:
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY" python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# The docs site must build with --strict (#173). setup-python so `make docs` can
# create its venv regardless of what the runner image ships.
docs:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-python@v5
with:
python-version: '3.12'
- run: make docs
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build. # Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -207,8 +218,14 @@ jobs:
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a # dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push # failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal. # re-run exercises verify-stack, so we still get the signal.
#
# Main only, not on PRs: the runner shares the lab node with the deployed stack, and a second
# full stack per PR was what got the runner OOM-killed (#182). PRs still gate on every job above;
# the live-stack check runs once per merge. A plain event `if` keeps the implicit success(), so it
# is not the status-function case from gotchas §7.
verify-stack: verify-stack:
needs: [mutation] needs: [mutation]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -231,6 +248,9 @@ jobs:
- name: RegisterRecord objecttype registered + published - name: RegisterRecord objecttype registered + published
id: registerrecord id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ClamAV scans a stream (EICAR found, clean OK)
id: clamav
run: CLAMAV_TIMEOUT=120 make verify-clamav
- name: ACL ↔ OpenZaak integration tests - name: ACL ↔ OpenZaak integration tests
id: acl id: acl
run: make verify-acl run: make verify-acl
@@ -270,6 +290,7 @@ jobs:
OBJECTEN: ${{ steps.objecten.outcome }} OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }} REGISTERRECORD: ${{ steps.registerrecord.outcome }}
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }} OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
CLAMAV: ${{ steps.clamav.outcome }}
ACL: ${{ steps.acl.outcome }} ACL: ${{ steps.acl.outcome }}
NRC: ${{ steps.nrc.outcome }} NRC: ${{ steps.nrc.outcome }}
PROJECTION: ${{ steps.projection.outcome }} PROJECTION: ${{ steps.projection.outcome }}
@@ -292,6 +313,7 @@ jobs:
echo "| Objecten API + token | $(icon "$OBJECTEN") |" echo "| Objecten API + token | $(icon "$OBJECTEN") |"
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |" echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |" echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
echo "| ClamAV INSTREAM scan | $(icon "$CLAMAV") |"
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |" echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
echo "| OpenZaak → NRC | $(icon "$NRC") |" echo "| OpenZaak → NRC | $(icon "$NRC") |"
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |" echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
@@ -311,7 +333,7 @@ jobs:
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: 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 objecten-celery 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 clamav tempo prometheus grafana 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down
+7 -1
View File
@@ -31,6 +31,12 @@ jobs:
# origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing # origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
# through the labs Caddy"). # through the labs Caddy").
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }} KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
# `true` fills in the medewerker OTP step for the public demo (chart value
# demo.otpAutofill). The fixture secret is committed: demo only.
OTP_AUTOFILL: ${{ vars.OTP_AUTOFILL }}
# Tempo for the services' traces, e.g. http://tempo.monitoring.svc:4317 (the
# cluster monitoring stack, Infra repo). Empty = the chart default.
OTEL_ENDPOINT: ${{ vars.OTEL_ENDPOINT }}
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -97,7 +103,7 @@ jobs:
make k8s-reseed \ make k8s-reseed \
TALOS_HOST=${TALOS_HOST:-localhost} \ TALOS_HOST=${TALOS_HOST:-localhost} \
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \ K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL}" K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL} --set demo.otpAutofill=${OTP_AUTOFILL:-false}${OTEL_ENDPOINT:+ --set otelEndpoint=$OTEL_ENDPOINT}"
# `dev` is a mutable tag and helm sees an unchanged pod template, so the # `dev` is a mutable tag and helm sees an unchanged pod template, so the
# new images only land on a restart (pullPolicy is already Always). # new images only land on a restart (pullPolicy is already Always).
+4
View File
@@ -61,3 +61,7 @@ __pycache__/
TestResults/ TestResults/
test-output/ test-output/
tests/e2e/playwright-report.json tests/e2e/playwright-report.json
# MkDocs build (`make docs`)
.venv-docs/
site/
+18 -3
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # 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) # (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. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten clamav
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # 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 # bind-mounted, because bind mounts don't reach sibling containers on the
@@ -43,11 +43,11 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
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-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 k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help .PHONY: ci lint build unit mutation frontend docs 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-clamav 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 k8s-lint k8s-drift k8s-registry k8s-images k8s-seed k8s-up k8s-reseed k8s-portals k8s-down k8s-purge help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions) ## 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). ## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation frontend verify ci: lint build unit mutation frontend docs verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required) ## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test # Tests run in their own phase, ahead of the build. The @angular/build:unit-test
@@ -81,6 +81,15 @@ unit:
python3 infra/test_playwright_summary.py python3 infra/test_playwright_summary.py
python3 infra/test_portal_caddyfiles.py python3 infra/test_portal_caddyfiles.py
## docs: build the MkDocs site with --strict (a broken link or nav entry fails)
# Pinned in a throwaway venv: Material 9.7 is the last line on MkDocs 1.x, and MkDocs
# 2.0 drops the plugin/theme system this site relies on. Publishing is a separate
# decision (#173); this only proves the site builds.
docs:
python3 -m venv .venv-docs
.venv-docs/bin/pip install --quiet mkdocs==1.6.1 mkdocs-material==9.7.7
.venv-docs/bin/mkdocs build --strict
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below 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` # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break # makes `make mutation` work from a fresh clone. Each service owns its config + break
@@ -213,6 +222,11 @@ verify-registerrecord:
verify-objecten-notifications: verify-objecten-notifications:
bash infra/run-objecten-notifications-check.sh bash infra/run-objecten-notifications-check.sh
## verify-clamav: assert clamd detects EICAR and passes a clean stream over INSTREAM (S-28),
## against the already-running stack.
verify-clamav:
bash infra/run-clamav-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## 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` ## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead. ## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -221,6 +235,7 @@ verify:
docker compose -f $(COMPOSE) up -d --build docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \ @bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \ WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-clamav-check.sh \
&& bash infra/run-acl-integration.sh \ && bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \ && bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \ && bash infra/run-projection-check.sh \
@@ -0,0 +1,108 @@
# ADR-0035: The deployed stack is published through the existing labs Caddy
- **Status:** Accepted
- **Date:** 2026-09-25
- **Deciders:** Respellion engineering
- **Slice:** [#177](https://git.labs.respellion.tech/eho/register-referentie/issues/177) —
that issue proposed the opposite (an in-cluster Caddy edge); this ADR records why the
host-side option won. Implemented in #179, #180 and #181.
## Context
The stack deploys to a single-node Talos VM (ADR-0033, #175). Until now it was only usable
through five SSH port-forwards: the portals' OIDC flow uses PKCE, PKCE needs
`crypto.subtle`, and browsers expose that only in a **secure context**, meaning HTTPS or a
`localhost` origin. A NodePort on the VM's address is neither. We want a URL a demo
audience can simply open.
Three facts about where things run shape the answer:
- The Talos VM is a libvirt guest on a **Fedora hypervisor in the office**, behind NAT
with no public address. The only way in from outside is an existing reverse SSH tunnel
(`autossh-reverse-tunnel.service`) into an `openssh-server` container on the labs
server.
- The **labs server** (public IP) already runs Caddy for `*.labs.respellion.tech`, with
the wildcard certificate (DNS-01 via Cloudflare) and ports 80/443. Every other labs
service is published there (repo `Infra`, `infra/development/`).
- #177 proposed a Caddy **inside the cluster**, fed by a layer-4 forward on the host, so
that routing and certificates would be cluster state. That assumes the public IP is on
the hypervisor. It isn't: the hypervisor has no inbound path, and 80/443 on the labs
server are already taken by the labs Caddy.
## Decision
**Publish the portals and Keycloak through the existing labs Caddy. Carry the traffic to
the cluster over a second reverse SSH tunnel from the hypervisor.**
```
browser ─https─▶ labs Caddy ─▶ openssh-server:3014x/30180
─reverse SSH tunnel─▶ Fedora hypervisor ─▶ Talos NodePorts
```
- **Hostnames** under the existing wildcard: `big-register` (openbaar), `big-mijn`
(self-service), `big-behandel`, `big-beheer`, and `big-auth` (Keycloak, with `/admin*`
answered 404).
- **Tunnel:** `big-portals-tunnel.service` on the hypervisor (repo `Infra`)
reverse-forwards the five browser-facing NodePorts into `openssh-server`. It is
separate from the access tunnel on `:6667`, so a failed forward can't cut SSH access.
Caddy joins the `openssh_default` network to reach the tunnel ends.
- **Keycloak's issuer** is the public origin. The chart value `keycloakUrl` replaces
`host` + NodePort in one helper, `big.keycloakUrl`, which feeds both `KC_HOSTNAME` and
the portals' `config.json` authority, so the two cannot drift (ADR-0010). The deploy
workflow sets it from the `KEYCLOAK_URL` repository variable.
- **`KC_PROXY_HEADERS=xforwarded`:** `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` builds the token,
userinfo and certs URLs from the request. That request reaches Keycloak as plain HTTP,
so the URLs came out `http://` and browsers blocked them as mixed content. Trusting
Caddy's `X-Forwarded-Proto` keeps them HTTPS. In-cluster calls send no such header and
still use `keycloak:8080`.
- **Demo MFA (optional):** `demo.otpAutofill` (`OTP_AUTOFILL`) makes the `big-demo` theme
(`infra/keycloak/themes/big-demo`) Keycloak's default. Its script fills in and submits
the medewerker OTP from the fixture secret (ADR-0031), so the step is visibly enforced
without an authenticator. It is off by default.
### Alternatives considered
- **In-cluster Caddy edge (#177, PR #178).** It would keep routes and certificates in
cluster state. But it needs a public inbound path to the hypervisor that doesn't exist,
plus a second certificate authority beside the labs Caddy, which already holds the
wildcard. Closed unmerged.
- **Port-forward on the office router to the hypervisor.** This opens the office network
itself to the internet. Rejected.
- **Move the cluster to a host with a public IP.** It would remove the tunnel, but it's a
bigger change than publishing one demo. It remains the natural step if the stack
outgrows a lab VM.
- **Keep the SSH port-forwards.** Fine for one developer, but not something you can send
to someone.
## Consequences
**Positive**
- Real hostnames and HTTPS, so PKCE works in any browser with no client-side setup.
- No new certificate handling: the labs Caddy's wildcard covers the new hosts.
- The chart stays edge-agnostic. With `keycloakUrl` empty it renders exactly as before,
so compose, CI and the `localhost` workflow are untouched.
**Negative / costs**
- **Routing lives outside the cluster**, in the Infra repo's Caddyfile. That is exactly
what #177 wanted to avoid. Adding a portal means changing three places: a NodePort in
the chart, a forward in the tunnel unit, and a host in the Caddyfile.
- **Two SSH hops in the data path.** If the hypervisor or the tunnel is down, the
portals return 502 even though the cluster is healthy.
- **One issuer string.** With `keycloakUrl` set, the `localhost` port-forward workflow
(runbook §5) can no longer log in.
- **Keycloak trusts `X-Forwarded-*`** from anything that reaches it. Today that is only
in-cluster callers and the tunnel. `KC_PROXY_TRUSTED_ADDRESSES` can narrow it if the
NodePort is ever exposed more widely.
- **The portals are public.** Anyone with the link can log in with the committed test
credentials, and with `OTP_AUTOFILL` on, no second factor stands in the way. That is
acceptable for synthetic data. Put the labs Caddy's Azure `authorize` in front of the
`big-*` hosts if the audience must be restricted.
**Follow-up**
- Runbook: `docs/runbooks/kubernetes-talos.md`, "Publishing through the labs Caddy".
- Dev-mode Keycloak generates new signing keys on every restart, and the BFF re-fetches
them at most every 5 minutes, so expect a few minutes of 401s after a Keycloak restart.
Persisting Keycloak's database (runbook §6) would remove that.
@@ -0,0 +1,56 @@
# ADR-0036: Uploaded documents are scanned by ClamAV in the Domain Service, fail closed
- **Status:** Accepted
- **Date:** 2026-10-02
- **Deciders:** Respellion engineering
- **Slice:** proposed in [#190](https://git.labs.respellion.tech/eho/register-referentie/issues/190);
clamd deployed in [#191](https://git.labs.respellion.tech/eho/register-referentie/issues/191) (S-28),
scanning wired in [#192](https://git.labs.respellion.tech/eho/register-referentie/issues/192) (S-29).
## Context
A zorgprofessional's diploma upload goes portal → BFF → Domain (`ProvideDocuments`) →
ACL → OpenZaak. Nothing on that path looks at the file. It is not checked for malware,
and nobody checks that it is a PDF. Behandelaars open these files later, so the
register stores, and then serves, whatever a citizen sends.
Scanning needs a signature engine that stays up to date. That means a new peer service,
and that makes it an ADR (CLAUDE.md §14).
## Decision
1. **Engine:** the ClamAV daemon (`clamd`), official image `clamav/clamav`, pinned tag,
as its own service in compose and in the Helm chart. `freshclam` in the same container
keeps the signatures current, and they live on a volume.
2. **Where the check lives:** in the **Domain Service**, behind an `IDocumentScanner` port
in `Big.Application`. "Only a clean PDF is stored and unblocks beoordeling" is a rule
of the provide-documents use case. The BFF is a thin proxy (§8.3), and the ACL
translates ZGW and nothing else (§8.1). A check in the domain also covers every
entry point, not just the portal.
3. **Protocol:** the adapter in `Big.Infrastructure` speaks clamd's INSTREAM protocol over
`TcpClient`: `zINSTREAM\0`, length-prefixed chunks, a zero-length terminator, then a
`stream: OK` or `stream: <name> FOUND` reply. That is a few lines of code, so we add
**no NuGet package** for it (nClam and similar).
4. **Fail closed:** if clamd can't be reached, the upload is refused (503). Nothing is
stored and the document wait stays open. We never store an unscanned file.
5. **Type check:** content must also start with `%PDF-`, checked **after** the scan.
clamd matches EICAR (and many real signatures) only at the start of a file, so a type
check in front of the scan would report malware as merely "not a PDF". The check also
refuses a renamed non-PDF that is clean.
## Consequences
- One more long-running service. clamd holds its signatures in memory (about 1 GB idle).
`ConcurrentDatabaseReload no` stops a signature reload from holding a second copy,
but clamd pauses scans for the few seconds a reload takes. Compose caps it at
`mem_limit: 2g`, and the chart requests 1200Mi. This counts against the verify-stack
runner's memory ceiling (#182).
- The first start downloads about 300 MB of signatures from the ClamAV CDN, so the
runner and the cluster node need outbound internet (as `seed-zaaktype` already does).
The CDN rate-limits by IP. A CI runner that starts fresh often can get throttled, and
then the health check doesn't go green. If that happens, mirror the signatures
(`cvdupdate`) rather than retrying.
- Tests use the EICAR test string, built from two halves so the repo itself does not trip
an on-access scanner. No real malware is ever committed.
- Infected or non-PDF uploads are refused with 422 and a business message. We don't keep
a quarantine copy: a refused file is simply not stored.
+6 -2
View File
@@ -19,9 +19,10 @@ and CI cannot drift:
| `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK | | `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK | | `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `frontend` | `make frontend` → Nx lint/test/build for the four portals | pnpm + Node | | `frontend` | `make frontend` → Nx lint/test/build for the four portals | pnpm + Node |
| `docs` | `make docs` → `mkdocs build --strict` in a pinned venv (fails on a broken link or nav entry; the site is not published yet, #173) | Python 3 |
| `k8s` | `make k8s-lint` (render + schema-check the Helm chart) → `make k8s-drift` (chart still describes the same stack as `infra/docker-compose.yml`) | pinned `helm` binary + `docker compose` | | `k8s` | `make k8s-lint` (render + schema-check the Helm chart) → `make k8s-drift` (chart still describes the same stack as `infra/docker-compose.yml`) | pinned `helm` binary + `docker compose` |
| `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK | | `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) | | `verify-stack` | **push to `main` only, skipped on PRs** (#182) — the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs > **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
> **sequentially**, so booting OpenZaak once (instead of once per check) is the > **sequentially**, so booting OpenZaak once (instead of once per check) is the
@@ -57,9 +58,12 @@ dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in
make mutation # dotnet tool restore + dotnet stryker on the ACL make mutation # dotnet tool restore + dotnet stryker on the ACL
``` ```
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json). Config lives in `services/acl/stryker-config.json`.
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped. (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
`Acl.slnx` leaves out `Acl.IntegrationTests`: it needs a live OpenZaak, and Stryker
runs every test project in the solution, so keeping it in makes 8 tests fail in the
initial run (#174).
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it **Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%** sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
+39
View File
@@ -15,6 +15,7 @@ those containers share a filesystem — or a `localhost` — breaks.
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks | | `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) | | `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
| `upload-artifact@v3` fails with "Artifact service responded with 500" | mark the upload `continue-on-error: true` (server-side; issue #62) | `.gitea/workflows/ci.yaml` (`mutation` job) | | `upload-artifact@v3` fails with "Artifact service responded with 500" | mark the upload `continue-on-error: true` (server-side; issue #62) | `.gitea/workflows/ci.yaml` (`mutation` job) |
| PR says "out-of-date" but *Update branch* fails ("Unable to update pull request") | push a new head SHA (`commit --amend --no-edit` + `push --force-with-lease`) | §10, server-side |
--- ---
@@ -289,3 +290,41 @@ re-run hides the failed one, so keep the failing job id from the original report
- Remember `concurrency.cancel-in-progress: true` in `ci.yaml`: a new push to the same - Remember `concurrency.cancel-in-progress: true` in `ci.yaml`: a new push to the same
ref, or a re-run, kills the in-flight run the same way. Check `run_attempt` before ref, or a re-run, kills the in-flight run the same way. Check `run_attempt` before
concluding a job hung. concluding a job hung.
---
## 10. A retargeted stacked PR says "out-of-date" but *Update branch* fails
**Symptom** — the PR shows *This branch is out-of-date with the base branch* and
*This pull request is blocked because it's outdated* (branch protection
`block_on_outdated_branch` on `main`), yet **Update branch** answers *Unable to update
pull request* (API: `HeadBranch of PR NN is up to date`). `git merge-base` confirms the
branch sits on the tip of `main`. Seen on #194 (#195).
**Why** — Gitea 1.27 keeps two answers to "is it behind?":
- The banner and the merge block read a **stored** `pull_request.commits_behind`
(`MergeBlockedByOutdatedBranch`: `CommitsBehind > 0`).
- *Update branch* recomputes it **live** from git (`services/pull/update.go`) and refuses
when nothing is behind.
The stored count is only refreshed by a push to the head or base branch, or by a
retarget. #194 was stacked on #193; its rebased branch was force-pushed in the **same
second** that merging #193 deleted the parent branch and Gitea retargeted #194 to `main`.
The retarget compared `main` against `refs/pull/194/head` before the push queue had
updated that ref (the old head missed the squash commit → `CommitsBehind = 1`); the push
handler's own resync ran against the deleted old base and failed. Nothing pushed
afterwards, so the stale `1` stuck. Close/reopen does not recompute it.
**Fix** — give the head a new SHA with the same content; the push resyncs the count:
```bash
git commit --amend --no-edit # new committer date → new SHA
git push --force-with-lease origin <branch>
```
PR CI re-runs on the new SHA (the old statuses don't carry over).
**Avoid it** — when the parent of a stacked PR merges, let Gitea finish retargeting the
child to `main` (its timeline shows *changed target branch*) **before** pushing the
rebased child branch.
+15
View File
@@ -356,6 +356,7 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
| Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value | | Login redirects but the portal stays logged out, or the BFF answers 401 | `TALOS_HOST` doesn't match the address in the browser's URL bar — issuer mismatch. Re-run `make k8s-up` with the right value |
| A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness | | A portal returns 502 on `/self-service/…` | the BFF is unreachable from the portal pod: check `kubectl -n big get svc bff` and the BFF's own readiness |
| Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` | | Public register empty after a submit | usually a wiped `emptyDir` database (§6): `make k8s-reseed`. Confirm with `kubectl -n big logs deploy/event-subscriber \| grep 42P01` |
| `clamav` not Ready for minutes | first start downloads ~300 MB of signatures, which needs outbound internet. A `429`/`cool-down` in `kubectl -n big logs deploy/clamav` means the ClamAV CDN is throttling this IP (ADR-0036) |
| `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` | | `helm upgrade` fails with `cannot patch … with kind Job` | see §7 — use `make k8s-reseed` |
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) | | Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` | | A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
@@ -403,6 +404,8 @@ upgrade path.
## Publishing through the labs Caddy ## Publishing through the labs Caddy
Why this route and not an in-cluster edge: [ADR-0035](../architecture/adr-0035-public-access-through-the-labs-caddy.md).
The portals can be reached on real hostnames through the Caddy that already fronts The portals can be reached on real hostnames through the Caddy that already fronts
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain: `*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
@@ -430,6 +433,18 @@ make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500 \
For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value. For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value.
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string. With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
Staff logins still hit the enforced OTP step. For a demo, set the repository variable
`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo`
theme, which fills in and submits the code from the fixture secret, so the step is visible
but needs no authenticator. Keycloak restarts when the value flips. Demo only — the secret
is committed.
The theme lives in `infra/keycloak/themes/big-demo/` and is seeded as the `rr-kc-theme`
ConfigMap by `infra/helm/seed-configmaps.sh` on every deploy. Keycloak runs `start-dev`,
which doesn't cache themes, so an edit shows up about a minute after the ConfigMap changes.
A *new* theme file also needs a key in the seed script and a path in the keycloak `files`
in `values.yaml`.
One-time setup: One-time setup:
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo 1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""S-28 (#191): prove clamd is up, has signatures loaded, and scans a stream over INSTREAM.
The EICAR test file must come back FOUND and a clean payload OK — the same protocol the domain's
scanner adapter will speak (ADR-0036). EICAR is assembled from two halves so this file itself is
not flagged by an on-access scanner on a developer laptop. Stdlib only (python:3-slim).
"""
import os
import socket
import struct
import sys
import time
HOST = os.environ["CLAMAV"]
TIMEOUT = int(os.environ.get("CLAMAV_TIMEOUT", "60"))
EICAR = (r"X5O!P%@AP[4\PZX54(P^)7CC)7}$" + r"EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*").encode()
def instream(payload):
with socket.create_connection((HOST, 3310), timeout=30) as s:
s.sendall(b"zINSTREAM\0" + struct.pack(">I", len(payload)) + payload + struct.pack(">I", 0))
return s.recv(4096).rstrip(b"\0").decode()
deadline = time.time() + TIMEOUT
while True:
try:
clean, infected = instream(b"%PDF-1.4 clean"), instream(EICAR)
break
except OSError as e:
if time.time() > deadline:
sys.exit(f"FAIL: clamd at {HOST}:3310 unreachable: {e}")
time.sleep(3)
print(f"clean → {clean!r}; eicar → {infected!r}")
if clean != "stream: OK":
sys.exit("FAIL: clean payload was not reported OK")
if not infected.endswith("FOUND"):
sys.exit("FAIL: EICAR was not detected")
print("OK: clamd detects EICAR and passes a clean stream")
+21
View File
@@ -751,6 +751,26 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [cg] networks: [cg]
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
# that, at the cost of clamd pausing scans during a signature reload.
clamav:
image: docker.io/clamav/clamav:1.4.6
environment:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
# wait-healthy sees it as soon as the signatures are loaded.
healthcheck:
test: ["CMD-SHELL", "clamdcheck.sh"]
interval: 5s
start_period: 360s
mem_limit: 2g
volumes:
- clamav-db:/var/lib/clamav
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:
@@ -758,6 +778,7 @@ volumes:
projection-db: projection-db:
objecttypen-db: objecttypen-db:
objecten-db: objecten-db:
clamav-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL. # Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env: seed-env:
+21
View File
@@ -785,6 +785,26 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [cg] networks: [cg]
# ClamAV daemon (S-28, ADR-0036): the domain scans uploaded diplomas over clamd's INSTREAM
# protocol on :3310 before they reach OpenZaak (S-29). The first start downloads ~300 MB of
# signatures with freshclam; the volume keeps them across restarts. clamd holds them in memory
# (~1 GB), and a reload would briefly hold two copies — ConcurrentDatabaseReload off prevents
# that, at the cost of clamd pausing scans during a signature reload.
clamav:
image: docker.io/clamav/clamav:1.4.6
environment:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
# The image's own healthcheck (clamdcheck.sh: PING → PONG) polls every 30s; poll faster so
# wait-healthy sees it as soon as the signatures are loaded.
healthcheck:
test: ["CMD-SHELL", "clamdcheck.sh"]
interval: 5s
start_period: 360s
mem_limit: 2g
volumes:
- clamav-db:/var/lib/clamav
networks: [cg]
# ── Observability backplane (S-16a, ADR-0023) ────────────────────────────── # ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export # Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service # straight to it — no collector hop, S-16b), Prometheus scrapes service
@@ -836,6 +856,7 @@ volumes:
projection-db: projection-db:
objecttypen-db: objecttypen-db:
objecten-db: objecten-db:
clamav-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh # 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 # (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them. # runner. `external` keeps the names deterministic; the seed step manages them.
@@ -94,6 +94,10 @@ volumes:
{{- with .defaultMode }} {{- with .defaultMode }}
defaultMode: {{ . }} defaultMode: {{ . }}
{{- end }} {{- end }}
{{- with .items }}
items:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }} {{- end }}
{{- with $w.data }} {{- with $w.data }}
- name: data - name: data
@@ -125,13 +129,17 @@ volumes:
{{/* {{/*
Env list from a map. Every value is run through `tpl`, so values.yaml can name Env list from a map. Every value is run through `tpl`, so values.yaml can name
cluster-internal hosts ({{ .Release.Namespace }}) and the node address cluster-internal hosts ({{ .Release.Namespace }}) and the node address
({{ .Values.host }}) without the chart hard-coding either. ({{ .Values.host }}) without the chart hard-coding either. A value that renders
empty is left out, which is how a setting is made conditional on a chart value.
*/}} */}}
{{- define "big.env" -}} {{- define "big.env" -}}
{{- $root := index . 0 -}} {{- $root := index . 0 -}}
{{- range $k, $v := index . 1 }} {{- range $k, $v := index . 1 }}
{{- $val := tpl (toString $v) $root }}
{{- if $val }}
- name: {{ $k }} - name: {{ $k }}
value: {{ tpl (toString $v) $root | quote }} value: {{ $val | quote }}
{{- end }}
{{- end }} {{- end }}
{{- end -}} {{- end -}}
+53 -4
View File
@@ -30,6 +30,18 @@ host: 192.168.122.100
# portals' authority (runbook, "Publishing through the labs Caddy"). # portals' authority (runbook, "Publishing through the labs Caddy").
keycloakUrl: "" keycloakUrl: ""
# Where the .NET services send traces (OTLP gRPC). The default is the chart's own
# `tempo` workload (off by default, like compose). Point it at a Tempo outside the
# release, e.g. the cluster monitoring stack's http://tempo.monitoring.svc:4317 —
# with no Tempo at all, every export fails and is counted as a .NET exception.
otelEndpoint: http://tempo:4317
demo:
# Fill in and submit the medewerker OTP step from the fixture secret, so a public
# demo shows MFA enforced without an authenticator: makes the big-demo theme
# (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed.
otpAutofill: false
# Set when pulling from a private registry (e.g. the Gitea Container Registry). # Set when pulling from a private registry (e.g. the Gitea Container Registry).
imagePullSecrets: [] imagePullSecrets: []
@@ -154,10 +166,11 @@ envGroups:
NOTIFICATIONS_DISABLED: "false" NOTIFICATIONS_DISABLED: "false"
RUN_SETUP_CONFIG: "true" RUN_SETUP_CONFIG: "true"
# Traces for the .NET services. Always set, like compose: the exporter fails # Traces for the .NET services. Always set, like compose. With no Tempo behind
# harmlessly when Tempo is absent (services/*/Program.cs). # `otelEndpoint` the exporter fails quietly but throws on every batch, which
# shows up as HttpRequestException/SocketException in dotnet_exceptions_total.
otel: otel:
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317 OTEL_EXPORTER_OTLP_ENDPOINT: '{{ .Values.otelEndpoint }}'
OTEL_EXPORTER_OTLP_PROTOCOL: grpc OTEL_EXPORTER_OTLP_PROTOCOL: grpc
# ── Workloads ────────────────────────────────────────────────────────────────── # ── Workloads ──────────────────────────────────────────────────────────────────
@@ -277,11 +290,29 @@ workloads:
# this issuer back, which is what browser tokens carry (infra/host-browser.yml). # this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}' KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
# Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak
# keeps its stock theme and the mounted big-demo theme is unused.
KC_SPI_THEME_DEFAULT: '{{ if .Values.demo.otpAutofill }}big-demo{{ end }}'
# Behind a TLS proxy (keycloakUrl) the dynamic backchannel URLs — token,
# userinfo, certs — take their scheme from the request, which reaches Keycloak
# as plain http; trusting X-Forwarded-Proto keeps them https so the browser
# doesn't block them as mixed content. In-cluster calls send no such header.
KC_PROXY_HEADERS: xforwarded
ports: [{ name: http, port: 8080 }] ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm # TCP, not /health/ready on the management port: nothing here gates on realm
# import, and a wrong health path would leave the Service with no endpoints. # import, and a wrong health path would leave the Service with no endpoints.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 } probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }] files:
- { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }
# infra/keycloak/themes/big-demo, seeded by infra/helm/seed-configmaps.sh.
- configMap: rr-kc-theme
mountPath: /opt/keycloak/themes/big-demo
items:
- { key: login.properties, path: login/theme.properties }
- { key: otp-autofill.js, path: login/resources/js/otp-autofill.js }
- { key: account.properties, path: account/theme.properties }
- { key: admin.properties, path: admin/theme.properties }
- { key: email.properties, path: email/theme.properties }
# ── Flowable (S-03) ───────────────────────────────────────────────────────── # ── Flowable (S-03) ─────────────────────────────────────────────────────────
flowable-db: flowable-db:
@@ -557,6 +588,24 @@ workloads:
envFrom: [objecten] envFrom: [objecten]
waitFor: [objecten-db:5432, objecten-redis:6379] waitFor: [objecten-db:5432, objecten-redis:6379]
# ── ClamAV (S-28, ADR-0036) ─────────────────────────────────────────────────
# The domain scans uploaded diplomas over clamd's INSTREAM protocol (S-29).
# First start pulls ~300 MB of signatures, so the node needs outbound internet
# (like seed-zaaktype); the data volume keeps them when persistence is on.
clamav:
image: docker.io/clamav/clamav:1.4.6
env:
CLAMD_CONF_ConcurrentDatabaseReload: "no"
ports: [{ name: clamd, port: 3310 }]
data: { mountPath: /var/lib/clamav, size: 1Gi }
probe:
exec: { command: [clamdcheck.sh] }
periodSeconds: 5
failureThreshold: 72
resources:
requests: { memory: 1200Mi }
limits: { memory: 2Gi }
# ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ── # ── Bootstrap the flow, like the local compose stack does (S-B04, ADR-0020) ──
# Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the # Seeds + publishes the BIG zaaktype through the same FQDN the ACL uses, so the
# server-assigned URLs are host-consistent. The ACL then resolves them by # server-assigned URLs are host-consistent. The ACL then resolves them by
+9
View File
@@ -30,6 +30,15 @@ seed() { # name <kubectl --from-file args...>
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/" seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/" seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/" seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
# The big-demo login theme (demo.otpAutofill). ConfigMap keys are flat, so each
# file gets a key here and its path back in the keycloak `files` in values.yaml.
theme="$repo/infra/keycloak/themes/big-demo"
seed rr-kc-theme \
--from-file=login.properties="$theme/login/theme.properties" \
--from-file=otp-autofill.js="$theme/login/resources/js/otp-autofill.js" \
--from-file=account.properties="$theme/account/theme.properties" \
--from-file=admin.properties="$theme/admin/theme.properties" \
--from-file=email.properties="$theme/email/theme.properties"
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/" seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/" seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped: # register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the account page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v3
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the admin page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v2
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the email page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak
@@ -0,0 +1,24 @@
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
document.addEventListener('DOMContentLoaded', async () => {
const input = document.querySelector('input[name="otp"]');
if (!input || !input.form) return;
const key = await crypto.subtle.importKey('raw',
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// A code is single-use, so a second login in the same window spends the next
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
// so a rejected code can't turn into a submit loop.
const now = Math.floor(Date.now() / 30000);
let last = -1;
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
const counter = Math.max(now, last + 1);
const msg = new DataView(new ArrayBuffer(8));
msg.setBigUint64(0, BigInt(counter));
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
const o = mac[19] & 0x0f;
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
input.value = String(n).padStart(6, '0');
if (counter > now + 1) return;
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
input.form.requestSubmit();
});
@@ -0,0 +1,11 @@
# Demo login theme for the public Talos deployment: keycloak.v2 plus a script that
# fills in and submits the medewerker OTP step from the committed fixture secret
# (docs/runbooks/keycloak.md). Only used when the chart's demo.otpAutofill is on —
# it then becomes Keycloak's default theme. Never enable it anywhere real.
#
# Add styles, messages or template overrides here as in any Keycloak theme
# (https://www.keycloak.org/ui-customization/themes); new files must also be
# listed in infra/helm/seed-configmaps.sh and the keycloak `files` in values.yaml.
parent=keycloak.v2
import=common/keycloak
scripts=js/otp-autofill.js
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
#
# S-28 (#191): assert clamd scans over INSTREAM (EICAR → FOUND, clean → OK), against an
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
# runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
av="$(docker ps -q --filter 'name=[-_]clamav[-_][0-9]+$' | head -1)"
[ -n "$av" ] || { echo "ERROR: no running clamav container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$av" | head -1)"
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$av")"
echo ">> network=$net clamav=$ip"
cid="$(docker create --network "$net" -e "CLAMAV=$ip" -e "CLAMAV_TIMEOUT=${CLAMAV_TIMEOUT:-60}" \
python:3-slim python /clamav-check.py)"
docker cp "$here/clamav-check.py" "$cid:/clamav-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
+2
View File
@@ -56,6 +56,8 @@ nav:
- "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md - "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md
- "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md - "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md
- "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md - "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md
- "ADR-0035: Public access through the labs Caddy": architecture/adr-0035-public-access-through-the-labs-caddy.md
- "ADR-0036: Scan uploads with ClamAV": architecture/adr-0036-scan-uploads-with-clamav.md
- FDS-architectuur: - FDS-architectuur:
- Overzicht: architecture/fds/README.md - Overzicht: architecture/fds/README.md
- Componentview (L3): architecture/fds/c4-component-view.md - Componentview (L3): architecture/fds/c4-component-view.md
+3 -1
View File
@@ -1,7 +1,9 @@
<Solution> <Solution>
<!-- Stryker-only. Acl.IntegrationTests is left out on purpose: it needs a live
OpenZaak, so in the mutation job it fails its initial run (#174). The root
register-referentie.slnx still builds and lints it. -->
<Project Path="Acl.Api/Acl.Api.csproj" /> <Project Path="Acl.Api/Acl.Api.csproj" />
<Project Path="Acl.Application/Acl.Application.csproj" /> <Project Path="Acl.Application/Acl.Application.csproj" />
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" /> <Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="Acl.Tests/Acl.Tests.csproj" /> <Project Path="Acl.Tests/Acl.Tests.csproj" />
</Solution> </Solution>