## What & why Two changes, made and verified together on a real cluster. **S-24 / #25 — a Helm chart for the platform.** One chart, `infra/helm/big-reference`, whose `values.yaml` is a near-literal transcription of `infra/docker-compose.yml`, rendered by three generic templates (Deployment, Job, Service) over a `workloads` map. Adding a service is a values edit. `make k8s-lint` renders and schema-checks the whole stack without a cluster. The issue asked for a *sketch*; this is deployed and verified end to end (see below), which is more than it asked for — the part it asked for that is **not** here is the production-posture write-up (HA, secrets, backup), see Known gaps. **#166 — Caddy replaces nginx in the portals.** nginx resolves a variable `proxy_pass` upstream itself, using only the `resolver` directive and never `/etc/resolv.conf`'s search domains. That had cost two workarounds in one script: rewriting the resolver address for rootless podman, and injecting a full FQDN so the bare `bff` name could resolve on Kubernetes. Caddy dials per request through the system resolver, so `reverse_proxy bff:8080` works on every engine unchanged; `apps/portal-nginx-resolver.sh` and the chart's `BFF_HOST` env are deleted. Closes #25 Closes #166 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation — twice: the Caddyfile contract test before the Caddyfiles, `make k8s-lint` before the chart. - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issues (`refs #25` / `refs #166`). - [ ] CI green — awaiting the run on this PR (`make k8s-lint`, `dotnet format` and the new unit self-check pass locally; the compose e2e and mutation lanes are CI's). - [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes — the portal images were rebuilt and verified standalone, but a full `make up` run has not been done on this branch. Please confirm in review or let CI's smoke test speak. - [x] Docs updated — `docs/runbooks/kubernetes-talos.md` (new), `frontend-decisions.md`, `demo-script.md`, and the docs that named nginx. - [x] ADR added — ADR-0033 (chart) and ADR-0034 (Caddy). - [ ] Demo note in `docs/demo-script.md` — not added: the deployment target is not a user-visible slice, and the Caddy swap is invisible to the demo script beyond the wording fix included here. ## How it was verified Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk) under virt-manager: **29 pods ready and four bootstrap Jobs complete in under three minutes, zero restarts**, using ~4.4 GB of the VM's 10 GB. - Full Common Ground path: portal Caddy → BFF → domain → Flowable → ACL → OpenZaak + Objecten → NRC → event-subscriber → projection → public register (`INGEDIEND`, reference matching the submitted registration). - Werkbak read with an MFA'd medewerker token → 200. - The browser flow driven with Playwright against `http://localhost:30140`: secure context, `crypto.subtle` present, Keycloak form reached, login completed, **no console errors**. - Routing checked against a stub BFF: SPA fallback serves deep links, each portal proxies its own groups, and a portal does *not* proxy a neighbour's group. ## Notes for reviewers Three bugs this shook out, each fixed at the cause rather than the symptom: 1. **`command` vs `args`.** Compose's `command:` replaces the image CMD; Kubernetes' replaces the ENTRYPOINT. Transcribing one to the other broke every upstream image that relies on its entrypoint — postgres refused to run as root, Keycloak tried to exec `start-dev`. The chart now `fail`s at render time on `command`. 2. **Concurrent migrations.** Both `/setup_configuration.sh` and `/start.sh` run `manage.py migrate`; compose serialises them with `depends_on`, Kubernetes has no such edge, so the init Job and its web pod raced (`relation "zgw_consumers_service" already exists`). The four Django services now do both steps in order in the web pod — which also deletes four workloads. 3. **`emptyDir` databases are wiped by any pod-template change.** `make k8s-reseed` now also restarts `event-subscriber` and `projection-api`, which create the projection schema on start and otherwise keep writing to a schema-less database. Known gaps / follow-ups: - **Secrets.** `values.yaml` carries the dev credentials in plain text (`admin/admin`, the ZGW client secret, the two Objecten tokens) and the chart has no `Secret` objects. Fine for a laptop demo, and exactly what #25's "production posture" ADR should address — I suggest a follow-up issue rather than stretching this PR. - **No CI gate for the chart yet.** `make k8s-lint` exists but is not wired into `.gitea/workflows/ci.yaml`, and nothing enforces that the chart and the compose file stay in step. Worth a small follow-up. - **This is two slices in one PR.** They were built and verified together and the diff is entangled (the chart was written against Caddy from the start), so splitting now would mean re-creating an nginx-shaped chart to throw away. Happy to split if you'd rather. - **Rebased onto #161** (merged as #165) rather than merged, to keep the history linear. One conflict, in the `unit:` target where both branches add a self-check line — resolved by keeping both. #161's `infra/host-browser.yml` arrived with `/usr/share/nginx/html/config.json` and is fixed to `/usr/share/caddy/` inside the `feat(portals)` commit, so no commit on this branch leaves that overlay pointing at a path the images no longer have.Reviewed-on: #167
This commit was merged in pull request #167.
This commit is contained in:
@@ -510,7 +510,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||
# Caddy serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||
@@ -522,7 +522,7 @@ services:
|
||||
ports:
|
||||
- "8140:80"
|
||||
volumes:
|
||||
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
@@ -562,7 +562,7 @@ services:
|
||||
ports:
|
||||
- "8142:80"
|
||||
volumes:
|
||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
|
||||
@@ -496,7 +496,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
|
||||
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
|
||||
# Caddy serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
|
||||
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
|
||||
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
|
||||
self-service:
|
||||
@@ -507,7 +507,7 @@ services:
|
||||
ports:
|
||||
- "8140:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
@@ -520,7 +520,7 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
|
||||
# The openbaar (public) register portal: Caddy serves the Angular app and reverse-proxies
|
||||
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
|
||||
openbaar:
|
||||
build:
|
||||
@@ -530,7 +530,7 @@ services:
|
||||
ports:
|
||||
- "8141:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
@@ -541,7 +541,7 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
|
||||
# The behandel portal: Caddy serves the Angular app and reverse-proxies /behandel to the BFF.
|
||||
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
|
||||
behandel:
|
||||
build:
|
||||
@@ -551,7 +551,7 @@ services:
|
||||
ports:
|
||||
- "8142:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
@@ -564,7 +564,7 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
|
||||
# The beheer portal: Caddy serves the Angular app and reverse-proxies /beheer to the BFF.
|
||||
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
|
||||
beheer:
|
||||
build:
|
||||
@@ -574,7 +574,7 @@ services:
|
||||
ports:
|
||||
- "8143:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: v2
|
||||
name: big-reference
|
||||
description: >-
|
||||
The BIG reference stack (Common Ground) on Kubernetes — a port of
|
||||
infra/docker-compose.yml, aimed at a single-node Talos cluster.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: dev
|
||||
@@ -0,0 +1,25 @@
|
||||
{{ .Chart.Name }} {{ .Chart.Version }} deployed to namespace {{ .Release.Namespace }}.
|
||||
|
||||
Watch it converge (the upstream Django services migrate on first boot, so the
|
||||
first bring-up takes a few minutes):
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get pods -w
|
||||
kubectl -n {{ .Release.Namespace }} get jobs
|
||||
|
||||
Every bootstrap Job must reach Completions 1/1:
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if and (ne $w.enabled false) $w.job }}
|
||||
- {{ $name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
Open in a browser (add {{ .Values.host }} to /etc/hosts if you use a name):
|
||||
{{- range $name, $port := .Values.nodePorts }}
|
||||
{{- $w := index $.Values.workloads $name }}
|
||||
{{- if ne $w.enabled false }}
|
||||
{{ printf "%-16s http://%s:%v" $name $.Values.host $port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
Test users are in docs/synthetic-data.md. If a pod is stuck in
|
||||
ContainerCreating on a missing ConfigMap, run: make k8s-seed
|
||||
@@ -0,0 +1,142 @@
|
||||
{{/*
|
||||
One pod spec for every workload, Deployment and Job alike. The chart is
|
||||
values-driven on purpose: `.Values.workloads` is a near-literal transcription of
|
||||
infra/docker-compose.yml, so the two stacks can be diffed by eye instead of by
|
||||
archaeology. Adding a service is a values edit, not a template edit.
|
||||
|
||||
Called as: include "big.podspec" (dict "root" $ "name" $name "w" $w)
|
||||
*/}}
|
||||
{{- define "big.podspec" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $name := .name -}}
|
||||
{{- $w := .w -}}
|
||||
{{- with $root.Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with $w.waitFor }}
|
||||
initContainers:
|
||||
- name: wait-for-deps
|
||||
image: {{ $root.Values.images.busybox }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
for t in {{ join " " . }}; do
|
||||
echo "waiting for $t"
|
||||
until nc -z "${t%:*}" "${t#*:}"; do sleep 2; done
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "big.image" (dict "root" $root "name" $name "w" $w) }}
|
||||
# Only this repo's images get the configured policy: their `dev` tag is mutable.
|
||||
# Upstream tags are pinned, so IfNotPresent keeps them out of pod-template diffs —
|
||||
# which matters because a changed template makes a Job unpatchable (immutable).
|
||||
imagePullPolicy: {{ if $w.own }}{{ $root.Values.images.pullPolicy }}{{ else }}IfNotPresent{{ end }}
|
||||
{{- if $w.command }}
|
||||
{{- fail (printf "workload %s: use `args`, not `command` — compose's `command:` replaces CMD, but Kubernetes' `command:` replaces the image ENTRYPOINT (postgres would run as root, keycloak would exec `start-dev`)" $name) }}
|
||||
{{- end }}
|
||||
{{- with $w.args }}
|
||||
args:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.envFrom }}
|
||||
envFrom:
|
||||
{{- range . }}
|
||||
- configMapRef:
|
||||
# optional: an env group whose feature is disabled (e.g. otel) simply
|
||||
# isn't rendered, and the pod must still start.
|
||||
name: {{ printf "%s-env" . }}
|
||||
optional: true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.env }}
|
||||
env:
|
||||
{{- include "big.env" (list $root .) | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.ports }}
|
||||
ports:
|
||||
{{- range . }}
|
||||
- name: {{ .name }}
|
||||
containerPort: {{ .targetPort | default .port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.probe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or $w.files $w.data }}
|
||||
volumeMounts:
|
||||
{{- range $w.files }}
|
||||
- name: {{ .configMap }}
|
||||
mountPath: {{ .mountPath }}
|
||||
{{- with .subPath }}
|
||||
subPath: {{ . }}
|
||||
{{- end }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- with $w.data }}
|
||||
- name: data
|
||||
mountPath: {{ .mountPath }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if or $w.files $w.data }}
|
||||
volumes:
|
||||
{{- range $w.files }}
|
||||
- name: {{ .configMap }}
|
||||
configMap:
|
||||
name: {{ .configMap }}
|
||||
{{- with .defaultMode }}
|
||||
defaultMode: {{ . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.data }}
|
||||
- name: data
|
||||
{{- if $root.Values.persistence.storageClass }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ $name }}-data
|
||||
{{- else }}
|
||||
# No StorageClass configured: the databases are emptyDir, so the stack needs
|
||||
# no CSI driver to come up. Data then lives as long as the pod does — see
|
||||
# docs/runbooks/kubernetes-talos.md for switching on local-path.
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/* Image ref: `own: true` workloads are built from this repo, everything else is upstream. */}}
|
||||
{{- define "big.image" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $w := .w -}}
|
||||
{{- if $w.own -}}
|
||||
{{- $ref := printf "%s/%s:%s" $root.Values.images.repositoryPrefix .name $root.Values.images.tag -}}
|
||||
{{- with $root.Values.images.registry }}{{ printf "%s/%s" . $ref }}{{ else }}{{ $ref }}{{ end }}
|
||||
{{- else -}}
|
||||
{{- $w.image -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
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
|
||||
({{ .Values.host }}) without the chart hard-coding either.
|
||||
*/}}
|
||||
{{- define "big.env" -}}
|
||||
{{- $root := index . 0 -}}
|
||||
{{- range $k, $v := index . 1 }}
|
||||
- name: {{ $k }}
|
||||
value: {{ tpl (toString $v) $root | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "big.labels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{- /*
|
||||
Shared env blocks — the Kubernetes equivalent of the YAML anchors in
|
||||
infra/docker-compose.yml (&oz-env, &nrc-env, &objecttypen-env, &objecten-env).
|
||||
A workload picks them up with `envFrom`, so the web/celery/init variants of an
|
||||
upstream image stay guaranteed-identical, and `kubectl get cm oz-env -o yaml`
|
||||
shows what a pod actually got.
|
||||
|
||||
The *file* inputs (setup_configuration data.yaml, Keycloak realms, BPMN/DMN, the
|
||||
seed scripts) are NOT here: they live in the repo and are turned into ConfigMaps
|
||||
by infra/helm/seed-configmaps.sh, exactly as infra/seed-config.sh streams them
|
||||
into the compose config volumes. Copying them into the chart would fork them.
|
||||
*/ -}}
|
||||
{{- range $group, $env := .Values.envGroups }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ $group }}-env
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" (printf "%s-env" $group)) | nindent 4 }}
|
||||
data:
|
||||
{{- range $k, $v := $env }}
|
||||
{{ $k }}: {{ tpl (toString $v) $ | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- /*
|
||||
Portal OIDC config. The images bake config.json with the compose authority
|
||||
(keycloak:8080), which a browser outside the cluster cannot resolve; these
|
||||
ConfigMaps mount over it with the node address Keycloak's issuer is pinned to
|
||||
(KC_HOSTNAME below), so the token the browser gets and the issuer the BFF
|
||||
discovers are the same string. Same mechanism as infra/host-browser.yml.
|
||||
*/ -}}
|
||||
{{- range $realm := list "digid" "medewerker" }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: portal-config-{{ $realm }}
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }}
|
||||
data:
|
||||
config.json: |
|
||||
{ "authority": "{{ printf "http://%s:%v" $.Values.host (index $.Values.nodePorts "keycloak") }}/realms/{{ $realm }}" }
|
||||
{{- end }}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if and (ne $w.enabled false) (not $w.job) }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
# Recreate, not RollingUpdate: single node, ReadWriteOnce volumes, and nothing
|
||||
# here is HA — a second pod would just fight the first for the disk.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ $name }}
|
||||
app.kubernetes.io/instance: {{ $.Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
{{- /*
|
||||
A ConfigMap mounted with subPath never picks up updates, so a portal whose
|
||||
config.json content changed has to be rolled. Hashing only the values that
|
||||
render it keeps the churn off the databases — an emptyDir database that is
|
||||
recreated for no reason loses its data (see the runbook §6).
|
||||
*/}}
|
||||
{{- range $w.files }}
|
||||
{{- if hasPrefix "portal-config-" .configMap }}
|
||||
annotations:
|
||||
checksum/portal-config: {{ printf "%s|%v" $.Values.host (index $.Values.nodePorts "keycloak") | sha256sum }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }}
|
||||
spec:
|
||||
{{- include "big.podspec" (dict "root" $ "name" $name "w" $w) | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- /*
|
||||
The one-shot bootstrap containers from compose (oz-init, nrc-init, flowable-init,
|
||||
the *-init setup_configuration runs, the zaaktype seed and the NRC abonnement)
|
||||
become Jobs. All of them are idempotent, so ordering is not enforced with hooks:
|
||||
each waits for the ports it needs (waitFor) and Kubernetes retries the rest.
|
||||
A wiped database is re-seeded by `make k8s-reseed`.
|
||||
*/ -}}
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if and (ne $w.enabled false) $w.job }}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
|
||||
app.kubernetes.io/component: init
|
||||
spec:
|
||||
backoffLimit: 20
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 8 }}
|
||||
app.kubernetes.io/component: init
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
{{- include "big.podspec" (dict "root" $ "name" $name "w" $w) | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if and (ne $w.enabled false) $w.data }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ $name }}-data
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
|
||||
# Keep the databases when the release is uninstalled; `make k8s-purge` drops them.
|
||||
annotations:
|
||||
helm.sh/resource-policy: keep
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: {{ $.Values.persistence.storageClass }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ $w.data.size | default "2Gi" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- /*
|
||||
Service names are the compose service names, verbatim: the portals' Caddy
|
||||
proxies to http://bff:8080 and the upstream setup_configuration files name
|
||||
http://openzaak:8000 / http://nrc-web:8000, so in-cluster DNS has to answer to
|
||||
exactly those names. Do not rename a workload without checking both.
|
||||
|
||||
.Values.nodePorts is the single place a port is published outside the cluster;
|
||||
a workload listed there gets a NodePort on its first (only) port.
|
||||
*/ -}}
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if and (ne $w.enabled false) $w.ports }}
|
||||
{{- $nodePort := index $.Values.nodePorts $name }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
labels:
|
||||
{{- include "big.labels" (dict "root" $ "name" $name) | nindent 4 }}
|
||||
spec:
|
||||
type: {{ if $nodePort }}NodePort{{ else }}ClusterIP{{ end }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ $name }}
|
||||
app.kubernetes.io/instance: {{ $.Release.Name }}
|
||||
ports:
|
||||
{{- range $i, $p := $w.ports }}
|
||||
- name: {{ $p.name }}
|
||||
port: {{ $p.port }}
|
||||
targetPort: {{ $p.targetPort | default $p.port }}
|
||||
{{- if and $nodePort (eq $i 0) }}
|
||||
nodePort: {{ $nodePort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,608 @@
|
||||
# Values for the BIG reference stack on Kubernetes.
|
||||
#
|
||||
# `workloads` is a near-literal transcription of infra/docker-compose.yml — same
|
||||
# service names, same images, same env, same one-shots — so the two stacks can be
|
||||
# diffed by eye. Read that file's comments for the *why* behind each setting; only
|
||||
# the deviations forced by Kubernetes are re-explained here.
|
||||
#
|
||||
# Every env value is rendered with Helm's `tpl`, so it may use:
|
||||
# {{ .Release.Namespace }} — for a cluster-internal FQDN
|
||||
# {{ .Values.host }} — the node address a browser reaches the cluster on
|
||||
#
|
||||
# Deviations from compose, all of them consequences of the platform:
|
||||
# * The compose stack hands the ACL and the seeds OpenZaak's *container IP*,
|
||||
# because OpenZaak and NRC validate URLs with Django's URLValidator and a
|
||||
# single-label host ("openzaak") is rejected. In Kubernetes the service FQDN
|
||||
# (openzaak.<ns>.svc.cluster.local) is already multi-label, so the IP dance and
|
||||
# the `objecten.local` network alias both disappear.
|
||||
# * `depends_on: service_healthy` becomes a `waitFor` init container (TCP wait)
|
||||
# plus readiness probes. Ordering is otherwise not enforced: every bootstrap
|
||||
# job is idempotent and Kubernetes retries.
|
||||
# * The published ports are NodePorts (see `nodePorts`), not host ports.
|
||||
|
||||
# The address a browser outside the cluster uses to reach the node: your Talos
|
||||
# VM's IP. It pins Keycloak's issuer and the portals' OIDC authority to one
|
||||
# string, so browser tokens and the BFF's discovered issuer agree.
|
||||
host: 192.168.122.100
|
||||
|
||||
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
|
||||
imagePullSecrets: []
|
||||
|
||||
images:
|
||||
# Where the images built from THIS repo live. Empty = the bare
|
||||
# `register-referentie/<svc>:dev` names, which only works if the node already
|
||||
# has them. On Talos it never does — point this at a registry the node can
|
||||
# reach (see docs/runbooks/kubernetes-talos.md).
|
||||
registry: ""
|
||||
repositoryPrefix: register-referentie
|
||||
tag: dev
|
||||
# Applies to this repo's images only (see _helpers.tpl). Always, because `dev`
|
||||
# is a mutable tag: with IfNotPresent the node keeps the first image it pulled
|
||||
# and `make k8s-images` would appear to do nothing. The registry is in-cluster,
|
||||
# so a re-pull is local and cheap — but the pods do depend on it being up.
|
||||
pullPolicy: Always
|
||||
busybox: docker.io/library/busybox:stable
|
||||
|
||||
persistence:
|
||||
# Empty = every database is an emptyDir, so the stack comes up on a bare
|
||||
# cluster with no CSI driver. Set to a StorageClass (e.g. `local-path`) to keep
|
||||
# the data across pod restarts.
|
||||
storageClass: ""
|
||||
|
||||
# The only place a port is published outside the cluster. A workload listed here
|
||||
# gets a NodePort on its single port; everything else stays ClusterIP.
|
||||
nodePorts:
|
||||
openzaak: 30000
|
||||
nrc-web: 30001
|
||||
objecttypen: 30020
|
||||
objecten: 30021
|
||||
bff: 30080
|
||||
flowable-rest: 30090
|
||||
self-service: 30140
|
||||
openbaar: 30141
|
||||
behandel: 30142
|
||||
beheer: 30143
|
||||
keycloak: 30180
|
||||
grafana: 30300
|
||||
|
||||
# ── Shared env blocks (the compose YAML anchors) ────────────────────────────────
|
||||
envGroups:
|
||||
|
||||
oz:
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: dev-only-not-for-production
|
||||
DB_HOST: oz-db
|
||||
DB_NAME: openzaak
|
||||
DB_USER: openzaak
|
||||
DB_PASSWORD: openzaak
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: oz-redis:6379/0
|
||||
CACHE_AXES: oz-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://oz-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
OPENZAAK_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
|
||||
nrc:
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: dev-only-not-for-production
|
||||
DB_HOST: nrc-db
|
||||
DB_NAME: opennotificaties
|
||||
DB_USER: opennotificaties
|
||||
DB_PASSWORD: opennotificaties
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: nrc-redis:6379/0
|
||||
CACHE_AXES: nrc-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://nrc-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
NOTIFICATION_SEC_INTERVAL: "5"
|
||||
|
||||
objecttypen:
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
|
||||
SECRET_KEY: dev-only-not-for-production
|
||||
DB_HOST: objecttypen-db
|
||||
DB_NAME: objecttypes
|
||||
DB_USER: objecttypes
|
||||
DB_PASSWORD: objecttypes
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecttypen-redis:6379/0
|
||||
CACHE_AXES: objecttypen-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
|
||||
objecten:
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objects.conf.docker
|
||||
SECRET_KEY: dev-only-not-for-production
|
||||
DB_HOST: objecten-db
|
||||
DB_NAME: objects
|
||||
DB_USER: objects
|
||||
DB_PASSWORD: objects
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecten-redis:6379/0
|
||||
CACHE_AXES: objecten-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
|
||||
# Traces for the .NET services. Always set, like compose: the exporter fails
|
||||
# harmlessly when Tempo is absent (services/*/Program.cs).
|
||||
otel:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
|
||||
# ── Workloads ──────────────────────────────────────────────────────────────────
|
||||
# Per entry: image | own (built here) · args · envFrom (env groups) · env
|
||||
# ports · probe (a literal readinessProbe) · files (ConfigMap mounts) · data
|
||||
# (a database volume) · waitFor (host:port to wait for) · job · enabled
|
||||
#
|
||||
# `args` (never `command`) is the compose `command:` equivalent: compose replaces
|
||||
# the image's CMD, and so does Kubernetes' `args` — Kubernetes' `command` would
|
||||
# replace the ENTRYPOINT instead. The chart fails to render if you use `command`.
|
||||
workloads:
|
||||
|
||||
# ── OpenZaak (S-01) ─────────────────────────────────────────────────────────
|
||||
oz-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
args: [postgres, -c, max_connections=300]
|
||||
env:
|
||||
POSTGRES_USER: openzaak
|
||||
POSTGRES_PASSWORD: openzaak
|
||||
POSTGRES_DB: openzaak
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 4Gi }
|
||||
probe:
|
||||
exec:
|
||||
command: [sh, -c, "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q"]
|
||||
periodSeconds: 5
|
||||
|
||||
oz-redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports: [{ name: redis, port: 6379 }]
|
||||
probe: { tcpSocket: { port: 6379 } }
|
||||
openzaak:
|
||||
image: docker.io/openzaak/open-zaak:1.28.2
|
||||
# setup_configuration first, then the server — in ONE container, on purpose.
|
||||
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
|
||||
# separate init Job (as compose has, ordered by depends_on) races this pod for
|
||||
# the same database and Django fails with "relation already exists".
|
||||
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
|
||||
envFrom: [oz]
|
||||
ports: [{ name: http, port: 8000 }]
|
||||
# /admin/ answers 302 when Django is up — a redirect counts as ready.
|
||||
probe:
|
||||
httpGet: { path: /admin/, port: 8000 }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
files: [{ configMap: rr-oz-config, mountPath: /app/setup_configuration }]
|
||||
waitFor: [oz-db:5432, oz-redis:6379]
|
||||
|
||||
oz-celery:
|
||||
image: docker.io/openzaak/open-zaak:1.28.2
|
||||
args: [/celery_worker.sh]
|
||||
envFrom: [oz]
|
||||
waitFor: [oz-db:5432, oz-redis:6379]
|
||||
|
||||
# ── Open Notificaties / NRC (S-01-c) ────────────────────────────────────────
|
||||
nrc-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
args: [postgres, -c, max_connections=300]
|
||||
env:
|
||||
POSTGRES_USER: opennotificaties
|
||||
POSTGRES_PASSWORD: opennotificaties
|
||||
POSTGRES_DB: opennotificaties
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
|
||||
probe:
|
||||
exec: { command: [pg_isready, -U, opennotificaties, -d, opennotificaties] }
|
||||
periodSeconds: 5
|
||||
|
||||
nrc-redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports: [{ name: redis, port: 6379 }]
|
||||
probe: { tcpSocket: { port: 6379 } }
|
||||
nrc-web:
|
||||
image: docker.io/openzaak/open-notificaties:1.16.1
|
||||
# setup_configuration first, then the server — in ONE container, on purpose.
|
||||
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
|
||||
# separate init Job (as compose has, ordered by depends_on) races this pod for
|
||||
# the same database and Django fails with "relation already exists".
|
||||
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
|
||||
envFrom: [nrc]
|
||||
ports: [{ name: http, port: 8000 }]
|
||||
probe:
|
||||
httpGet: { path: /admin/, port: 8000 }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
files: [{ configMap: rr-nrc-config, mountPath: /app/setup_configuration }]
|
||||
waitFor: [nrc-db:5432, nrc-redis:6379, openzaak:8000]
|
||||
|
||||
nrc-celery:
|
||||
image: docker.io/openzaak/open-notificaties:1.16.1
|
||||
args: [/celery_worker.sh]
|
||||
envFrom: [nrc]
|
||||
waitFor: [nrc-db:5432, nrc-redis:6379]
|
||||
|
||||
# Without beat, notifications are accepted but never delivered (ADR-0007).
|
||||
nrc-beat:
|
||||
image: docker.io/openzaak/open-notificaties:1.16.1
|
||||
args: [/celery_beat.sh]
|
||||
envFrom: [nrc]
|
||||
waitFor: [nrc-db:5432, nrc-redis:6379]
|
||||
|
||||
# ── Keycloak (S-02) ─────────────────────────────────────────────────────────
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1
|
||||
args: [start-dev, --import-realm]
|
||||
env:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
# 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
|
||||
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
|
||||
KC_HOSTNAME: "http://{{ .Values.host }}:{{ index .Values.nodePorts \"keycloak\" }}"
|
||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
# 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.
|
||||
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
|
||||
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }]
|
||||
|
||||
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
|
||||
flowable-db:
|
||||
image: docker.io/library/postgres:16
|
||||
env:
|
||||
POSTGRES_USER: flowable
|
||||
POSTGRES_PASSWORD: flowable
|
||||
POSTGRES_DB: flowable
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
|
||||
probe:
|
||||
exec: { command: [pg_isready, -U, flowable, -d, flowable] }
|
||||
periodSeconds: 5
|
||||
|
||||
flowable-rest:
|
||||
image: docker.io/flowable/flowable-rest:latest
|
||||
env:
|
||||
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
|
||||
SPRING_DATASOURCE_USERNAME: flowable
|
||||
SPRING_DATASOURCE_PASSWORD: flowable
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
# Every REST path needs basic auth, so an httpGet probe would read 401 as
|
||||
# not-ready. TCP is the honest signal here.
|
||||
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 20 }
|
||||
waitFor: [flowable-db:5432]
|
||||
|
||||
# Deploys the BPMN to the process engine and the DMN to the DMN engine as two
|
||||
# separate deployments — flowable-rest does not cascade one into the other
|
||||
# (S-13, ADR-0016). Idempotent.
|
||||
flowable-init:
|
||||
job: true
|
||||
image: docker.io/curlimages/curl:latest
|
||||
args:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||
until curl -sf -u rest-admin:test "$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||
echo "diploma-eligibility DMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||
fi
|
||||
if curl -s -u rest-admin:test "$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie BPMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||
fi
|
||||
files: [{ configMap: rr-fl-bpmn, mountPath: /work }]
|
||||
waitFor: [flowable-rest:8080]
|
||||
|
||||
# ── ACL ─────────────────────────────────────────────────────────────────────
|
||||
acl:
|
||||
own: true
|
||||
envFrom: [otel]
|
||||
env:
|
||||
OTEL_SERVICE_NAME: acl
|
||||
# The FQDN, not `openzaak`: OpenZaak rejects a single-label host on
|
||||
# zaak-create. It must be the same host the zaaktype was seeded through
|
||||
# (see the seed-zaaktype job) so the URLs stay host-consistent (ADR-0009).
|
||||
Acl__OpenZaak__BaseUrl: "http://openzaak.{{ .Release.Namespace }}.svc.cluster.local:8000/"
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
# Objecten reflects the request Host into the object url it returns, and
|
||||
# publishes that url to NRC — which rejects a single-label host. The FQDN
|
||||
# replaces compose's `objecten.local` alias (ADR-0029).
|
||||
Acl__Objecten__BaseUrl: "http://objecten.{{ .Release.Namespace }}.svc.cluster.local:8000/"
|
||||
Acl__Objecten__Token: 1234567890abcdef1234567890abcdef12345678
|
||||
# Short name on purpose: Objecten only accepts an objecttype URL that
|
||||
# matches the one it was configured with (infra/objecten/setup_configuration
|
||||
# /data.yaml → http://objecttypen:8000/api/v2/).
|
||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||
Acl__Objecten__ObjecttypenToken: 0123456789abcdef0123456789abcdef01234567
|
||||
Acl__Objecten__ObjecttypeName: RegisterRecord
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
|
||||
|
||||
# ── BIG Domain Service (S-05) ───────────────────────────────────────────────
|
||||
domain:
|
||||
own: true
|
||||
envFrom: [otel]
|
||||
env:
|
||||
OTEL_SERVICE_NAME: domain
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
|
||||
|
||||
# ── BFF ─────────────────────────────────────────────────────────────────────
|
||||
bff:
|
||||
own: true
|
||||
envFrom: [otel]
|
||||
env:
|
||||
OTEL_SERVICE_NAME: bff
|
||||
# In-cluster authority: Keycloak's discovery document returns the pinned
|
||||
# KC_HOSTNAME issuer, which is what browser tokens carry (ADR-0010).
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
Downstream__Acl__BaseUrl: http://acl:8080/
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
|
||||
|
||||
# ── Read projection (S-06) ──────────────────────────────────────────────────
|
||||
projection-db:
|
||||
image: docker.io/library/postgres:16
|
||||
env:
|
||||
POSTGRES_USER: projection
|
||||
POSTGRES_PASSWORD: projection
|
||||
POSTGRES_DB: projection
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
|
||||
probe:
|
||||
exec: { command: [pg_isready, -U, projection, -d, projection] }
|
||||
periodSeconds: 5
|
||||
|
||||
event-subscriber:
|
||||
own: true
|
||||
envFrom: [otel]
|
||||
env:
|
||||
OTEL_SERVICE_NAME: event-subscriber
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
EventSubscriber__Webhook__AuthToken: Bearer big-reference-notifications
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
|
||||
# It migrates the projection schema on start and throws if the DB is absent.
|
||||
waitFor: [projection-db:5432]
|
||||
|
||||
projection-api:
|
||||
own: true
|
||||
envFrom: [otel]
|
||||
env:
|
||||
OTEL_SERVICE_NAME: projection-api
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports: [{ name: http, port: 8080 }]
|
||||
probe: { httpGet: { path: /health, port: 8080 }, periodSeconds: 5 }
|
||||
waitFor: [projection-db:5432]
|
||||
|
||||
# ── Portals (S-08/S-09/S-12/S-15) ───────────────────────────────────────────
|
||||
# Caddy serves the Angular app and reverse-proxies its endpoint group to
|
||||
# http://bff:8080 — hence the Service must stay named `bff`. Caddy resolves that
|
||||
# name through the system resolver, so the DNS search domains apply and no
|
||||
# upstream rewriting is needed here (ADR-0034).
|
||||
self-service:
|
||||
own: true
|
||||
ports: [{ name: http, port: 80 }]
|
||||
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
|
||||
files:
|
||||
- configMap: portal-config-digid
|
||||
mountPath: /usr/share/caddy/config.json
|
||||
subPath: config.json
|
||||
|
||||
openbaar:
|
||||
own: true
|
||||
ports: [{ name: http, port: 80 }]
|
||||
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
|
||||
|
||||
behandel:
|
||||
own: true
|
||||
ports: [{ name: http, port: 80 }]
|
||||
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
|
||||
files:
|
||||
- configMap: portal-config-medewerker
|
||||
mountPath: /usr/share/caddy/config.json
|
||||
subPath: config.json
|
||||
|
||||
beheer:
|
||||
own: true
|
||||
ports: [{ name: http, port: 80 }]
|
||||
probe: { httpGet: { path: /, port: 80 }, periodSeconds: 5 }
|
||||
files:
|
||||
- configMap: portal-config-medewerker
|
||||
mountPath: /usr/share/caddy/config.json
|
||||
subPath: config.json
|
||||
|
||||
# ── Objecttypen API (S-18a) ─────────────────────────────────────────────────
|
||||
objecttypen-db:
|
||||
image: docker.io/library/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: objecttypes
|
||||
POSTGRES_PASSWORD: objecttypes
|
||||
POSTGRES_DB: objecttypes
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
|
||||
probe:
|
||||
exec: { command: [pg_isready, -U, objecttypes] }
|
||||
periodSeconds: 5
|
||||
|
||||
objecttypen-redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports: [{ name: redis, port: 6379 }]
|
||||
probe: { tcpSocket: { port: 6379 } }
|
||||
objecttypen:
|
||||
image: docker.io/maykinmedia/objecttypes-api:3.4.2
|
||||
# setup_configuration first, then the server — in ONE container, on purpose.
|
||||
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
|
||||
# separate init Job (as compose has, ordered by depends_on) races this pod for
|
||||
# the same database and Django fails with "relation already exists".
|
||||
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
|
||||
envFrom: [objecttypen]
|
||||
ports: [{ name: http, port: 8000 }]
|
||||
probe:
|
||||
httpGet: { path: /admin/, port: 8000 }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
files: [{ configMap: rr-objecttypen-config, mountPath: /app/setup_configuration }]
|
||||
waitFor: [objecttypen-db:5432, objecttypen-redis:6379]
|
||||
|
||||
# The RegisterRecord objecttype + published version, over the API (S-18c,
|
||||
# ADR-0020/ADR-0027). The uuid is pinned — Objecten identifies it by uuid.
|
||||
registerrecord-init:
|
||||
job: true
|
||||
image: docker.io/library/python:3-slim
|
||||
args: [python, /config/register.py]
|
||||
env:
|
||||
OBJECTTYPEN: http://objecttypen:8000
|
||||
OBJECTTYPEN_TOKEN: 0123456789abcdef0123456789abcdef01234567
|
||||
SCHEMA: /config/registerrecord.schema.json
|
||||
files: [{ configMap: rr-registerrecord-config, mountPath: /config }]
|
||||
waitFor: [objecttypen:8000]
|
||||
|
||||
# ── Objecten API (S-18b) ────────────────────────────────────────────────────
|
||||
objecten-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
env:
|
||||
POSTGRES_USER: objects
|
||||
POSTGRES_PASSWORD: objects
|
||||
POSTGRES_DB: objects
|
||||
ports: [{ name: postgres, port: 5432 }]
|
||||
data: { mountPath: /var/lib/postgresql/data, size: 2Gi }
|
||||
probe:
|
||||
exec: { command: [pg_isready, -U, objects] }
|
||||
periodSeconds: 5
|
||||
|
||||
objecten-redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports: [{ name: redis, port: 6379 }]
|
||||
probe: { tcpSocket: { port: 6379 } }
|
||||
objecten:
|
||||
image: docker.io/maykinmedia/objects-api:3.4.0
|
||||
# setup_configuration first, then the server — in ONE container, on purpose.
|
||||
# Both /setup_configuration.sh and /start.sh run `manage.py migrate`, so a
|
||||
# separate init Job (as compose has, ordered by depends_on) races this pod for
|
||||
# the same database and Django fails with "relation already exists".
|
||||
args: [sh, -c, "/setup_configuration.sh && exec /start.sh"]
|
||||
envFrom: [objecten]
|
||||
ports: [{ name: http, port: 8000 }]
|
||||
probe:
|
||||
httpGet: { path: /admin/, port: 8000 }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
files: [{ configMap: rr-objecten-config, mountPath: /app/setup_configuration }]
|
||||
waitFor: [objecten-db:5432, objecten-redis:6379, objecttypen:8000]
|
||||
|
||||
# Delivers Objecten's notifications to NRC; without it every register write is
|
||||
# silently undelivered (ADR-0029).
|
||||
objecten-celery:
|
||||
image: docker.io/maykinmedia/objects-api:3.4.0
|
||||
args: [/celery_worker.sh]
|
||||
envFrom: [objecten]
|
||||
waitFor: [objecten-db:5432, objecten-redis:6379]
|
||||
|
||||
# ── 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
|
||||
# server-assigned URLs are host-consistent. The ACL then resolves them by
|
||||
# identificatie (S-27, ADR-0021) — nothing is injected back.
|
||||
# Publishing validates the resultaattype against the external Selectielijst
|
||||
# API, so the node needs outbound internet for this one job (ADR-0006).
|
||||
seed-zaaktype:
|
||||
job: true
|
||||
image: docker.io/library/python:3-slim
|
||||
args: [python, /seed/seed_catalogus.py]
|
||||
env:
|
||||
OZ_BASE: "http://openzaak.{{ .Release.Namespace }}.svc.cluster.local:8000"
|
||||
OZ_PUBLISH: "1"
|
||||
files: [{ configMap: rr-seed-scripts, mountPath: /seed }]
|
||||
waitFor: [openzaak:8000]
|
||||
|
||||
# Registers the NRC abonnement on the `objecten` kanaal pointing at the
|
||||
# event-subscriber, so register writes reach the projection (ADR-0030).
|
||||
# Without it the openbaar register stays empty. Restart-safe and idempotent.
|
||||
nrc-subscribe:
|
||||
job: true
|
||||
image: docker.io/library/python:3-slim
|
||||
args: [python, /seed/register-abonnement.py]
|
||||
env:
|
||||
NRC_BASE: http://nrc-web:8000
|
||||
# The script resolves this to an address for the callback URL; the FQDN
|
||||
# resolves to the Service's (stable) ClusterIP, which NRC's URLValidator
|
||||
# accepts — the compose stack uses the container IP for the same reason.
|
||||
SINK_HOST: "event-subscriber.{{ .Release.Namespace }}.svc.cluster.local"
|
||||
SINK_PORT: "8080"
|
||||
SINK_AUTH: Bearer big-reference-notifications
|
||||
files: [{ configMap: rr-seed-scripts, mountPath: /seed }]
|
||||
waitFor: [nrc-web:8000, event-subscriber:8080]
|
||||
|
||||
# ── Observability backplane (S-16a, ADR-0023) ───────────────────────────────
|
||||
# Off by default: these are built images too (config baked in), so switching
|
||||
# them on also means pushing three more images. Enable all three together.
|
||||
tempo:
|
||||
enabled: false
|
||||
own: true
|
||||
args: ["-config.file=/etc/tempo.yaml"]
|
||||
ports: [{ name: otlp, port: 4317 }, { name: http, port: 3200 }]
|
||||
|
||||
prometheus:
|
||||
enabled: false
|
||||
own: true
|
||||
ports: [{ name: http, port: 9090 }]
|
||||
|
||||
grafana:
|
||||
enabled: false
|
||||
own: true
|
||||
env:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
ports: [{ name: http, port: 3000 }]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Throwaway in-cluster OCI registry, published on NodePort 30500.
|
||||
#
|
||||
# Talos has no Docker daemon and no way to side-load an image, so the images built
|
||||
# from this repo must come from a registry. This one lives *inside* the cluster on
|
||||
# purpose: a registry on the laptop needs an inbound port opened on firewalld's
|
||||
# libvirt zone (root), while pushing from the laptop to the node is outbound and
|
||||
# always allowed. The node then pulls from its own NodePort.
|
||||
#
|
||||
# Talos must be told it speaks plain HTTP — see the machine.registries.mirrors
|
||||
# patch in docs/runbooks/kubernetes-talos.md. Storage is emptyDir: if this pod is
|
||||
# replaced, re-run `make k8s-images`.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: registry
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: registry
|
||||
namespace: registry
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy: { type: Recreate }
|
||||
selector:
|
||||
matchLabels: { app: registry }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: registry }
|
||||
spec:
|
||||
containers:
|
||||
- name: registry
|
||||
image: docker.io/library/registry:2
|
||||
env:
|
||||
- name: REGISTRY_STORAGE_DELETE_ENABLED
|
||||
value: "true"
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
readinessProbe:
|
||||
httpGet: { path: /v2/, port: 5000 }
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/registry
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: registry
|
||||
namespace: registry
|
||||
spec:
|
||||
type: NodePort
|
||||
selector: { app: registry }
|
||||
ports:
|
||||
- name: http
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
nodePort: 30500
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Turn the repo's config inputs into the ConfigMaps the Helm chart mounts.
|
||||
#
|
||||
# This is the Kubernetes sibling of infra/seed-config.sh: the upstream Common
|
||||
# Ground images are used verbatim and read their config from a mounted directory,
|
||||
# so the config has to be handed to the platform out-of-band. Compose gets it via
|
||||
# `docker cp` into external volumes; Kubernetes gets it as ConfigMaps created from
|
||||
# the files that already live in this repo. Copying those files into the chart
|
||||
# would fork them from the compose stack, so we don't.
|
||||
#
|
||||
# Idempotent: re-run after editing any data.yaml, then `make k8s-reseed`.
|
||||
#
|
||||
# Usage: seed-configmaps.sh [namespace] (default: big)
|
||||
set -euo pipefail
|
||||
|
||||
ns="${1:-big}"
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo="$(cd "$here/../.." && pwd)"
|
||||
|
||||
kubectl get namespace "$ns" >/dev/null 2>&1 || kubectl create namespace "$ns"
|
||||
|
||||
seed() { # name <kubectl --from-file args...>
|
||||
local name="$1"; shift
|
||||
kubectl create configmap "$name" -n "$ns" "$@" \
|
||||
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
echo " seeded configmap/$name"
|
||||
}
|
||||
|
||||
seed rr-oz-config --from-file="$repo/infra/openzaak/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-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
|
||||
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
|
||||
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
|
||||
# kubectl only takes regular files from a --from-file directory).
|
||||
seed rr-registerrecord-config --from-file="$repo/infra/objecttypen-registerrecord/"
|
||||
# The BPMN and the DMN are two separate Flowable deployments (S-13, ADR-0016).
|
||||
seed rr-fl-bpmn \
|
||||
--from-file="$repo/workflows/registratie.bpmn" \
|
||||
--from-file="$repo/workflows/diploma-eligibility.dmn"
|
||||
# The two bootstrap scripts the compose local stack runs as init containers
|
||||
# (S-B04, ADR-0020). Stdlib-only, so a plain python image can run them.
|
||||
seed rr-seed-scripts \
|
||||
--from-file="$repo/infra/openzaak/seed_catalogus.py" \
|
||||
--from-file="$repo/infra/local/register-abonnement.py"
|
||||
@@ -10,11 +10,11 @@ services:
|
||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||
self-service:
|
||||
volumes:
|
||||
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
|
||||
behandel:
|
||||
volumes:
|
||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
|
||||
# beheer is the same medewerker realm as behandel, so it reuses behandel's config verbatim.
|
||||
beheer:
|
||||
volumes:
|
||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-check for the portals' Caddyfiles — stdlib asserts, no framework.
|
||||
|
||||
Run: python3 infra/test_portal_caddyfiles.py (also runs in `make unit`).
|
||||
|
||||
Each portal serves its Angular app and reverse-proxies *its own* BFF endpoint group
|
||||
same-origin, so the browser never sees CORS and the DigiD token rides along (ADR-0010).
|
||||
The four files are near-identical, which makes a copy-paste slip cheap to introduce and
|
||||
expensive to find: proxying another portal's group hands a behandelaar's browser an
|
||||
endpoint its token isn't for, and the failure shows up as a 401 three services away.
|
||||
|
||||
What is asserted per portal: it proxies exactly its own groups to the BFF service, and it
|
||||
falls back to index.html so Angular's client-side routes survive a deep link / refresh.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
APPS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "apps")
|
||||
|
||||
# The self-service portal also renders the public register (S-09), so it proxies both.
|
||||
EXPECTED = {
|
||||
"self-service": {"/self-service/*", "/openbaar/*"},
|
||||
"openbaar": {"/openbaar/*"},
|
||||
"behandel": {"/behandel/*"},
|
||||
"beheer": {"/beheer/*"},
|
||||
}
|
||||
ALL_GROUPS = {g for groups in EXPECTED.values() for g in groups}
|
||||
|
||||
|
||||
def caddyfile(app):
|
||||
with open(os.path.join(APPS, app, "Caddyfile")) as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def proxied_groups(text):
|
||||
"""The path groups routed to the BFF: `handle <path> { reverse_proxy bff:8080 }`."""
|
||||
return {
|
||||
m.group(1)
|
||||
for m in re.finditer(r"handle\s+(\S+)\s*\{[^}]*reverse_proxy\s+bff:8080", text)
|
||||
}
|
||||
|
||||
|
||||
def test_each_portal_proxies_exactly_its_own_endpoint_groups():
|
||||
for app, expected in EXPECTED.items():
|
||||
got = proxied_groups(caddyfile(app))
|
||||
assert got == expected, f"{app}: proxies {got or '{}'}, expected {expected}"
|
||||
|
||||
|
||||
def test_no_portal_proxies_another_portals_group():
|
||||
for app, expected in EXPECTED.items():
|
||||
strays = proxied_groups(caddyfile(app)) & (ALL_GROUPS - expected)
|
||||
assert not strays, f"{app}: proxies another portal's group {strays}"
|
||||
|
||||
|
||||
def test_every_portal_falls_back_to_index_html():
|
||||
"""Angular routes client-side: an unknown path must serve the app, not a 404."""
|
||||
for app in EXPECTED:
|
||||
text = caddyfile(app)
|
||||
assert "try_files {path} /index.html" in text, f"{app}: no SPA fallback"
|
||||
assert "file_server" in text, f"{app}: nothing serves the built app"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
fn()
|
||||
print(f" ok {name}")
|
||||
print("portal Caddyfile self-check passed")
|
||||
Reference in New Issue
Block a user