Compare commits

..
Author SHA1 Message Date
notandClaude Opus 5.5 c7f06b35fa fix(infra): cap celery workers at 2 so the shared node stops OOM-killing CI (refs #182)
CI / lint (pull_request) Successful in 1m55s
CI / k8s (pull_request) Successful in 11s
CI / build (pull_request) Successful in 1m20s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 3m29s
CI / mutation (pull_request) Successful in 5m51s
CI / verify-stack (pull_request) Successful in 11m37s
Unset CELERY_WORKER_CONCURRENCY makes oz-celery and nrc-celery fork one
process per CPU — 22 each on the lab node, ~225 MB apiece — and Talos'
OOM controller was killing the runner mid-verify-stack. Same lever as the
uWSGI caps (#144/#145/#147); objecten-celery already defaults to 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:09:08 +02:00
not 804031eeb8 feat(k8s): publish the portals through the labs Caddy (refs #177) (#179)
CI / k8s (push) Successful in 14s
CI / build (push) Successful in 2m2s
CI / lint (push) Successful in 2m32s
CI / unit (push) Successful in 1m36s
CI / frontend (push) Successful in 3m3s
Deploy to Talos / deploy (push) Successful in 3m39s
CI / mutation (push) Successful in 5m31s
CI / verify-stack (push) Failing after 17m26s
## What & why

Makes the portals reachable on real hostnames through the Caddy that already fronts `*.labs.respellion.tech`, instead of five SSH port-forwards:

| URL | Service |
|---|---|
| `https://big-register.labs.respellion.tech` | openbaar |
| `https://big-mijn.labs.respellion.tech` | self-service |
| `https://big-behandel.labs.respellion.tech` | behandel |
| `https://big-beheer.labs.respellion.tech` | beheer |
| `https://big-auth.labs.respellion.tech` | Keycloak (`/admin` blocked) |

Chain: browser → labs Caddy (TLS) → `openssh-server` container → reverse SSH tunnel → Fedora host → Talos NodePorts. The Caddy routes and the tunnel unit are already on `main` in the Infra repo (`infra/development/`).

This repo's part:
- **Chart:** a `keycloakUrl` value. When set it replaces `host` + Keycloak's NodePort as the pinned issuer (`KC_HOSTNAME`) and the portals' OIDC authority. Both now come from one helper, `big.keycloakUrl`, so they can't drift apart (ADR-0010). Empty = rendered output identical to today.
- **Deploy workflow:** passes the `KEYCLOAK_URL` repo variable as `--set keycloakUrl=…`.
- **Runbook:** new section "Publishing through the labs Caddy".

Refs #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Infra/config change, no test added.)*
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green — all Gitea Actions jobs (or `make ci` green while no runner exists).
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(Compose untouched.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
- [ ] Demo note in `docs/demo-script.md` if user-visible.

## Notes for reviewers

- **This takes the option #177 rejects.** #177 proposes an in-cluster Caddy edge (branch `feat/177-public-tls-edge`). This PR uses the existing labs Caddy instead, because it already holds 80/443 and the wildcard certificate. So it only *refs* #177. If we go this way, #177's ADR should record the host-Caddy option instead.
- `make k8s-lint` and `infra/check-docs-nav.py` pass. I rendered the chart with and without `keycloakUrl`: empty gives the same output as before; set, it gives `https://big-auth.labs.respellion.tech` for both the issuer and the authority.
- Once `KEYCLOAK_URL` is set, the `localhost` port-forward workflow (runbook §5) no longer logs in, because the issuer is a single string.
- The portals are public, with no Azure `authorize` in front of them the way `marketing` has one. The test users use `test123`.
- Rollout after merge: install `big-portals-tunnel.service` on the Fedora host, run `docker compose up -d caddy` on the labs server, then set the `KEYCLOAK_URL` variable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #179
2026-09-25 08:16:37 +00:00
not 6cfcc4cf83 ci(deploy): deploy the stack to Talos on merge to main (closes #175) (#176)
CI / k8s (push) Successful in 6s
CI / unit (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / build (push) Canceled after 9s
CI / lint (push) Canceled after 24s
Deploy to Talos / deploy (push) Successful in 5m1s
## What & why

The chart has been deployable by hand since #25 and linted in CI since #168. This makes a
merged PR actually ship it to the Talos VM on the lab server.

`.gitea/workflows/deploy.yaml` runs on a push to `main` (a squash-merged PR) and on manual
dispatch:

1. **Tunnel** — neither the Kubernetes API nor the in-cluster registry is publicly reachable,
   so 6443, 30500 and 30141 are forwarded over the same SSH hop into the Fedora host that the
   Gitea-runner pipeline uses (`ssh -p 6667 user@labs.respellion.tech`).
2. **Images** — `make k8s-images K8S_REGISTRY=localhost:30500`, pushed *through* the tunnel.
3. **Deploy** — `make k8s-reseed TALOS_HOST=… K8S_REGISTRY=<vm-ip>:30500`, pulled by the node
   from its own NodePort.
4. **Roll** — `rollout restart` + `rollout status` on the nine repo deployments.
5. **Smoke** — `GET /openbaar/register` through the openbaar portal.

Three decisions worth the review:

- **One registry, two names.** The push target (`localhost:30500`, the tunnel) and the pull
  target (`<vm-ip>:30500`, the node's own NodePort) address the same store. The pull name has
  to be the one in the node's registry-mirror patch, which is what makes plain HTTP acceptable.
- **`k8s-reseed`, not `k8s-up`.** A Job's pod template is immutable, so a chart change to any
  bootstrap Job would otherwise fail the upgrade with `cannot patch … with kind Job`. The Jobs
  are idempotent by design, so re-running them every deploy is safe and removes that whole
  class of failure. Cost: a few minutes per deploy, and `seed-zaaktype` needs egress from the VM.
- **No re-run of the checks.** PR CI is the merge gate, so `main` is green by construction.
  Deploys **queue** (`cancel-in-progress: false`) — a `helm upgrade` killed half-way leaves the
  release in `pending-upgrade` and has to be unwedged by hand.

Settings on the repo (already added): secrets `TALOS_SSH_KEY` and `TALOS_KUBECONFIG`
(base64, and its `server:` must be `https://127.0.0.1:6443` — Talos puts `127.0.0.1` in the
apiserver cert SANs, so TLS still verifies through the tunnel); variables `TALOS_VM_IP`
(default `192.168.122.173`) and `TALOS_HOST` (default `localhost`).

Closes #175

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation — **n/a**: this is a deployment
      workflow with no unit under test. Its check is the run itself: `rollout status` and the
      public-register smoke both have to pass or the job fails. `make k8s-lint` / `make k8s-drift`
      (#168) already gate the chart it deploys.
- [x] Implementation — one workflow file, no production code touched.
- [x] Conventional Commits referencing the issue (`refs #175`).
- [ ] CI green — awaiting the run on this PR.
- [x] `docker compose up` unaffected — no service, image or compose file is touched.
- [x] Docs updated — `docs/runbooks/kubernetes-talos.md` §9 (the tunnel, the two registry
      names, the secrets table, the smoke) and a pointer from `docs/runbooks/ci.md`.
- [x] No ADR needed: no new dependency (kubectl/helm/crane are already prerequisites of the
      `k8s-*` targets), no service boundary moved, no CLAUDE.md §8 rule bent.
- [ ] Demo note — not user-visible.

## Notes for reviewers

- **The first deploy is the real test.** It cannot be dry-run: the tunnel, the secrets and the
  registry only exist on the lab server. Merging is how we find out; `Pods on failure` dumps
  `get pods,jobs` if it doesn't.
- **Known gap — the portals still aren't browsable.** PKCE needs a secure context, so a
  NodePort on an IP can't serve them (runbook §5); they need `make k8s-portals` or an SSH
  forward. Giving the server a hostname + TLS is the follow-up, and is where `TALOS_HOST`
  stops defaulting to `localhost`.
- **Databases are `emptyDir`.** Any change to a database pod's template wipes it; the
  `k8s-reseed` in the deploy re-runs the bootstrap, so the stack recovers, but submitted
  registrations do not. Persistence is runbook §6.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #176
2026-09-18 13:53:58 +00:00
7 changed files with 66 additions and 68 deletions
+7 -15
View File
@@ -27,10 +27,10 @@ jobs:
# `kubectl port-forward` — runbook §5. Override with repo variables. # `kubectl port-forward` — runbook §5. Override with repo variables.
TALOS_VM_IP: ${{ vars.TALOS_VM_IP }} TALOS_VM_IP: ${{ vars.TALOS_VM_IP }}
TALOS_HOST: ${{ vars.TALOS_HOST }} TALOS_HOST: ${{ vars.TALOS_HOST }}
# Set it and the stack is published over TLS on <sub>.<domain> by the # Set it when the labs Caddy publishes the portals: Keycloak's public https
# in-cluster edge (ADR-0035, runbook §10). Empty = NodePorts, as before. # origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
PUBLIC_DOMAIN: ${{ vars.PUBLIC_DOMAIN }} # through the labs Caddy").
PUBLIC_EMAIL: ${{ vars.PUBLIC_EMAIL }} KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -94,12 +94,10 @@ jobs:
# Job template from wedging the upgrade (`cannot patch … with kind Job`). # Job template from wedging the upgrade (`cannot patch … with kind Job`).
- name: Deploy the chart - name: Deploy the chart
run: | run: |
set -euo pipefail
publish="${PUBLIC_DOMAIN:+--set public.domain=$PUBLIC_DOMAIN --set public.email=${PUBLIC_EMAIL:-}}"
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="$publish" K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL}"
# `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).
@@ -115,12 +113,6 @@ jobs:
- name: Smoke the public register - name: Smoke the public register
run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors http://localhost:30141/openbaar/register run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors http://localhost:30141/openbaar/register
# Cluster-wide, not just `big`: the first thing that can fail is the registry - name: Pods on failure
# in its own namespace, and a scheduling problem shows up in the events, not
# in `rollout status` — which only ever says "timed out waiting".
- name: Pods and events on failure
if: failure() if: failure()
run: | run: kubectl -n big get pods,jobs || true
kubectl get pods -A -o wide || true
kubectl -n big get jobs || true
kubectl get events -A --sort-by=.lastTimestamp | tail -30 || true
+36 -50
View File
@@ -366,36 +366,6 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
every time a PR is squash-merged to `main` (and on demand via *Run workflow*). PR CI is the every time a PR is squash-merged to `main` (and on demand via *Run workflow*). PR CI is the
merge gate, so the workflow deploys without re-running the checks. merge gate, so the workflow deploys without re-running the checks.
**Prerequisite: the VM must have been installed with the §1 patch.** A stock Talos config
gives you a node that still carries the control-plane taint and knows nothing about the
plain-HTTP registry, and the deploy hits those in that order: the `registry` pod sits
`Pending` until `rollout status` times out, and once that is fixed every repo image fails to
pull. Two separate fixes:
```bash
# on the Fedora host — 1. let workloads onto the only node (§1)
export KUBECONFIG=~/talos-kubeconfig-local
kubectl taint node --all node-role.kubernetes.io/control-plane-
# 2. trust the in-cluster registry over plain HTTP (§2)
cat > /tmp/registry-patch.yaml <<'YAML'
machine:
registries:
mirrors:
"<TALOS_VM_IP>:30500":
endpoints:
- http://<TALOS_VM_IP>:30500
YAML
talosctl -n <TALOS_VM_IP> -e <TALOS_VM_IP> patch mc --patch @/tmp/registry-patch.yaml
```
Keep those two apart. On Talos 1.14 a patch that also sets
`cluster.allowSchedulingOnControlPlanes` is rejected with *".cluster.allowSchedulingOnControlPlanes
is already set in v1alpha1 config"* — the field moved out of the v1alpha1 schema, the same way
`machine.install` did (§1) — and the rejection takes the whole patch with it, so the mirror
silently doesn't land either. `kubectl taint` is the documented way (§1); it is undone if the
node ever re-registers, which is a reboot, not a deploy.
The cluster's API and registry are not exposed publicly, so the job forwards them over the The cluster's API and registry are not exposed publicly, so the job forwards them over the
same SSH hop the Gitea-runner pipeline uses: same SSH hop the Gitea-runner pipeline uses:
@@ -427,30 +397,46 @@ Settings, all on the repository in Gitea:
The last step smokes `GET /openbaar/register` through the openbaar portal, which exercises The last step smokes `GET /openbaar/register` through the openbaar portal, which exercises
portal → Caddy → BFF → projection. An empty register passes; a 502 does not. portal → Caddy → BFF → projection. An empty register passes; a 502 does not.
### Reaching the portals from a laptop
The deployed portals are pinned to `http://localhost:30180` for Keycloak (§5), so a browser
needs **all five** browser-facing ports on its own localhost — the portal alone is not
enough, and a missing Keycloak shows up as `ERR_CONNECTION_REFUSED` on
`/realms/*/.well-known/openid-configuration` followed by an opaque `ERROR Error: [object Object]`.
`make k8s-portals` does this when kubectl can reach the cluster; through the lab server one
SSH does it without a kubeconfig at all:
```bash
ssh -N -p 6667 \
-L 30140:<TALOS_VM_IP>:30140 -L 30141:<TALOS_VM_IP>:30141 \
-L 30142:<TALOS_VM_IP>:30142 -L 30143:<TALOS_VM_IP>:30143 \
-L 30180:<TALOS_VM_IP>:30180 \
user@labs.respellion.tech
```
Then the §5 table's URLs work as written. The admin UIs (OpenZaak, Flowable, …) need no
forward — they are server-rendered, so the VM's address is fine.
Not covered: the portals still need `make k8s-portals` (or an SSH forward) to be usable in a Not covered: the portals still need `make k8s-portals` (or an SSH forward) to be usable in a
browser, because PKCE needs a secure context (§5). Giving the server a hostname + TLS is the browser, because PKCE needs a secure context (§5). Giving the server a hostname + TLS is the
upgrade path. upgrade path.
## Publishing through the labs Caddy
The portals can be reached on real hostnames through the Caddy that already fronts
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
```
browser → Caddy (labs server, TLS) → openssh-server:3014x/30180
→ reverse SSH tunnel → Fedora host → <TALOS_VM_IP>:3014x/30180 (NodePorts)
```
| URL | NodePort |
|---|---|
| `https://big-register.labs.respellion.tech` | 30141 openbaar |
| `https://big-mijn.labs.respellion.tech` | 30140 self-service |
| `https://big-behandel.labs.respellion.tech` | 30142 behandel |
| `https://big-beheer.labs.respellion.tech` | 30143 beheer |
| `https://big-auth.labs.respellion.tech` | 30180 Keycloak (`/admin` blocked) |
HTTPS makes the portals a secure context, so PKCE works without port-forwards — but
Keycloak's issuer must be the public origin. Deploy with it:
```bash
make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500 \
K8S_SET="--set keycloakUrl=https://big-auth.labs.respellion.tech"
```
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.
One-time setup:
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
(instructions in the file).
2. Labs server: deploy the Infra `Caddyfile` + `compose.yml` (Caddy joins the
`openssh_default` network to reach the tunnel ends).
## What is not ported ## What is not ported
- **Observability** (Tempo, Prometheus, Grafana) is defined but disabled — those are built - **Observability** (Tempo, Prometheus, Grafana) is defined but disabled — those are built
+5
View File
@@ -57,6 +57,9 @@ services:
# share this anchor and ignore it — they don't run uwsgi. # share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1" UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2" UWSGI_THREADS: "2"
# Same lever for oz-celery: unset, the worker forks one process per CPU (22 on the lab node,
# ~225 MB each), which OOM-killed the shared runner mid-verify-stack. Only celery reads it.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db DB_HOST: oz-db
@@ -144,6 +147,8 @@ services:
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above. # 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1" UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2" UWSGI_THREADS: "2"
# Two celery workers, not one per CPU — see the oz-env note above.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db DB_HOST: nrc-db
@@ -135,6 +135,14 @@ cluster-internal hosts ({{ .Release.Namespace }}) and the node address
{{- end }} {{- end }}
{{- end -}} {{- end -}}
{{/*
The origin a browser reaches Keycloak on: the issuer Keycloak pins and the
authority the portals use, from one place so they cannot drift (ADR-0010).
*/}}
{{- define "big.keycloakUrl" -}}
{{- .Values.keycloakUrl | default (printf "http://%s:%v" .Values.host (index .Values.nodePorts "keycloak")) -}}
{{- end -}}
{{- define "big.labels" -}} {{- define "big.labels" -}}
app.kubernetes.io/name: {{ .name }} app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }} app.kubernetes.io/instance: {{ .root.Release.Name }}
@@ -40,5 +40,5 @@ metadata:
{{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }} {{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }}
data: data:
config.json: | config.json: |
{ "authority": "{{ printf "http://%s:%v" $.Values.host (index $.Values.nodePorts "keycloak") }}/realms/{{ $realm }}" } { "authority": "{{ include "big.keycloakUrl" $ }}/realms/{{ $realm }}" }
{{- end }} {{- end }}
@@ -28,7 +28,7 @@ spec:
{{- range $w.files }} {{- range $w.files }}
{{- if hasPrefix "portal-config-" .configMap }} {{- if hasPrefix "portal-config-" .configMap }}
annotations: annotations:
checksum/portal-config: {{ printf "%s|%v" $.Values.host (index $.Values.nodePorts "keycloak") | sha256sum }} checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }}
{{- end }} {{- end }}
{{- end }} {{- end }}
labels: labels:
+8 -1
View File
@@ -25,6 +25,11 @@
# string, so browser tokens and the BFF's discovered issuer agree. # string, so browser tokens and the BFF's discovered issuer agree.
host: 192.168.122.100 host: 192.168.122.100
# Set when a TLS proxy outside the cluster publishes Keycloak: the full origin, no
# trailing slash. It replaces `host` + Keycloak's NodePort as the issuer and the
# portals' authority (runbook, "Publishing through the labs Caddy").
keycloakUrl: ""
# 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: []
@@ -71,6 +76,7 @@ envGroups:
oz: oz:
UWSGI_PROCESSES: "1" UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2" UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: dev-only-not-for-production SECRET_KEY: dev-only-not-for-production
DB_HOST: oz-db DB_HOST: oz-db
@@ -93,6 +99,7 @@ envGroups:
nrc: nrc:
UWSGI_PROCESSES: "1" UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2" UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: dev-only-not-for-production SECRET_KEY: dev-only-not-for-production
DB_HOST: nrc-db DB_HOST: nrc-db
@@ -268,7 +275,7 @@ workloads:
# Pin the issuer to the address the browser uses, and let backchannel calls # Pin the issuer to the address the browser uses, and let backchannel calls
# keep using keycloak:8080 — the BFF discovers metadata in-cluster and gets # keep using keycloak:8080 — the BFF discovers metadata in-cluster and gets
# 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: "http://{{ .Values.host }}:{{ index .Values.nodePorts \"keycloak\" }}" KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
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