From 7a5840149cd71a1e4711d9a7a4ca36144b9ee9be Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 4 Sep 2026 17:25:46 +0200 Subject: [PATCH] feat(k8s): Helm chart for the whole stack on a single-node cluster (refs #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One chart 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 — so the two stacks can be diffed by eye instead of by archaeology, and adding a service is a values edit. Platform-forced deviations, each commented where it appears: - `args`, never `command`: compose replaces the image CMD, Kubernetes replaces the ENTRYPOINT. The chart fails to render on `command`, because the symptom (postgres refusing to run as root, Keycloak exec-ing `start-dev`) is nothing like the cause. - The four Django services apply their own setup_configuration in the web pod rather than in a separate init Job: both scripts migrate, and without compose's depends_on they race the same database. - OpenZaak and Objecten are addressed by service FQDN, because Django rejects a single-label host in a URL — the reason compose passes container IPs around. - NodePorts, no ingress; databases are emptyDir until persistence.storageClass is set, so the stack comes up on a cluster with no CSI driver. The upstream config inputs stay in the repo and become ConfigMaps via infra/helm/seed-configmaps.sh — the Kubernetes sibling of infra/seed-config.sh — so the compose stack and the chart cannot fork. infra/helm/registry.yaml runs an in-cluster registry because Talos cannot side-load an image and a laptop-side one needs a root-level firewall change. --- Makefile | 67 +- infra/helm/big-reference/Chart.yaml | 8 + infra/helm/big-reference/templates/NOTES.txt | 25 + .../helm/big-reference/templates/_helpers.tpl | 142 ++++ .../helm/big-reference/templates/config.yaml | 44 ++ .../big-reference/templates/deployments.yaml | 39 ++ infra/helm/big-reference/templates/jobs.yaml | 29 + infra/helm/big-reference/templates/pvcs.yaml | 22 + .../big-reference/templates/services.yaml | 35 + infra/helm/big-reference/values.yaml | 608 ++++++++++++++++++ infra/helm/registry.yaml | 60 ++ infra/helm/seed-configmaps.sh | 46 ++ 12 files changed, 1124 insertions(+), 1 deletion(-) create mode 100644 infra/helm/big-reference/Chart.yaml create mode 100644 infra/helm/big-reference/templates/NOTES.txt create mode 100644 infra/helm/big-reference/templates/_helpers.tpl create mode 100644 infra/helm/big-reference/templates/config.yaml create mode 100644 infra/helm/big-reference/templates/deployments.yaml create mode 100644 infra/helm/big-reference/templates/jobs.yaml create mode 100644 infra/helm/big-reference/templates/pvcs.yaml create mode 100644 infra/helm/big-reference/templates/services.yaml create mode 100644 infra/helm/big-reference/values.yaml create mode 100644 infra/helm/registry.yaml create mode 100755 infra/helm/seed-configmaps.sh diff --git a/Makefile b/Makefile index 8b4279d..56141f6 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) 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 help +.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-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) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -350,6 +350,71 @@ k8s-lint: helm lint $(K8S_CHART) helm template big $(K8S_CHART) -n $(K8S_NS) --set images.registry=registry.invalid:5000 >/dev/null +## k8s-registry: deploy the in-cluster image registry (NodePort 30500) +k8s-registry: + kubectl apply -f infra/helm/registry.yaml + kubectl -n registry rollout status deploy/registry --timeout=180s + +## k8s-images: build this repo's images (via compose) and push them to $(K8S_REGISTRY) +# `docker save | crane push` rather than `docker push`: the registry speaks plain +# HTTP, which the Docker daemon refuses without a root-level insecure-registries +# entry, while crane just takes --insecure. Install: see docs/runbooks/kubernetes-talos.md. +k8s-images: + @command -v crane >/dev/null || { echo "crane not found — see docs/runbooks/kubernetes-talos.md §0" >&2; exit 2; } + @test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=" >&2; exit 2; } + docker compose -f $(COMPOSE) build $(K8S_IMAGES) + @tar=$$(mktemp -t rr-img-XXXX.tar); \ + for i in $(K8S_IMAGES); do \ + docker save register-referentie/$$i:dev -o $$tar; \ + crane push --insecure $$tar $(K8S_REGISTRY)/register-referentie/$$i:dev; \ + done; rm -f $$tar + +## k8s-seed: create the ConfigMaps the chart mounts (upstream config + bootstrap scripts) +k8s-seed: + bash infra/helm/seed-configmaps.sh $(K8S_NS) + +## k8s-up: seed the config and install/upgrade the release +k8s-up: k8s-seed + @test -n "$(TALOS_HOST)" || { echo "set TALOS_HOST=" >&2; exit 2; } + @test -n "$(K8S_REGISTRY)" || { echo "set K8S_REGISTRY=" >&2; exit 2; } + helm upgrade --install big $(K8S_CHART) -n $(K8S_NS) --create-namespace \ + --set host=$(TALOS_HOST) --set images.registry=$(K8S_REGISTRY) $(K8S_SET) + kubectl -n $(K8S_NS) get pods + +## k8s-reseed: re-run the bootstrap jobs (after a database was wiped, or after +## changing a Job in the chart — Job pod templates are immutable, so a plain +## `helm upgrade` is rejected) +k8s-reseed: + kubectl -n $(K8S_NS) delete job -l app.kubernetes.io/component=init --ignore-not-found + $(MAKE) k8s-up + # The projection's schema is created on service start (Projection.ReadModel migrates in a + # hosted service), so a wiped database also needs these two restarted — otherwise they keep + # writing to a schema-less DB and fail with `relation "processed_notifications" does not exist`. + kubectl -n $(K8S_NS) rollout restart deploy/event-subscriber deploy/projection-api + kubectl -n $(K8S_NS) rollout status deploy/event-subscriber deploy/projection-api --timeout=180s + +## k8s-portals: forward the browser-facing services to localhost (Ctrl-C stops them all) +# The portals' OIDC flow needs a *secure context* for crypto.subtle (PKCE), and browsers +# only grant that to https or localhost — a NodePort on the VM's IP is neither. Forwarding +# to localhost on the same port numbers keeps Keycloak's pinned issuer valid. Deploy with +# TALOS_HOST=localhost for this to line up. +k8s-portals: + @echo "self-service http://localhost:30140 · openbaar :30141 · behandel :30142 · beheer :30143 · keycloak :30180" + @trap 'kill 0' INT TERM; \ + for f in self-service:30140:80 openbaar:30141:80 behandel:30142:80 beheer:30143:80 keycloak:30180:8080; do \ + svc=$${f%%:*}; rest=$${f#*:}; lport=$${rest%%:*}; rport=$${rest#*:}; \ + kubectl -n $(K8S_NS) port-forward --address 127.0.0.1 svc/$$svc $$lport:$$rport >/dev/null & \ + done; wait + +## k8s-down: uninstall the release (database PVCs are kept) +k8s-down: + helm uninstall big -n $(K8S_NS) + +## k8s-purge: uninstall AND drop the namespace, including the database volumes +k8s-purge: + -helm uninstall big -n $(K8S_NS) + kubectl delete namespace $(K8S_NS) --ignore-not-found + ## help: list available targets help: @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //' diff --git a/infra/helm/big-reference/Chart.yaml b/infra/helm/big-reference/Chart.yaml new file mode 100644 index 0000000..375a92f --- /dev/null +++ b/infra/helm/big-reference/Chart.yaml @@ -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 diff --git a/infra/helm/big-reference/templates/NOTES.txt b/infra/helm/big-reference/templates/NOTES.txt new file mode 100644 index 0000000..49774c6 --- /dev/null +++ b/infra/helm/big-reference/templates/NOTES.txt @@ -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 diff --git a/infra/helm/big-reference/templates/_helpers.tpl b/infra/helm/big-reference/templates/_helpers.tpl new file mode 100644 index 0000000..5b24ded --- /dev/null +++ b/infra/helm/big-reference/templates/_helpers.tpl @@ -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 -}} diff --git a/infra/helm/big-reference/templates/config.yaml b/infra/helm/big-reference/templates/config.yaml new file mode 100644 index 0000000..8771b2d --- /dev/null +++ b/infra/helm/big-reference/templates/config.yaml @@ -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 }} diff --git a/infra/helm/big-reference/templates/deployments.yaml b/infra/helm/big-reference/templates/deployments.yaml new file mode 100644 index 0000000..02ac150 --- /dev/null +++ b/infra/helm/big-reference/templates/deployments.yaml @@ -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 }} diff --git a/infra/helm/big-reference/templates/jobs.yaml b/infra/helm/big-reference/templates/jobs.yaml new file mode 100644 index 0000000..c9b05e1 --- /dev/null +++ b/infra/helm/big-reference/templates/jobs.yaml @@ -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 }} diff --git a/infra/helm/big-reference/templates/pvcs.yaml b/infra/helm/big-reference/templates/pvcs.yaml new file mode 100644 index 0000000..dcad3c5 --- /dev/null +++ b/infra/helm/big-reference/templates/pvcs.yaml @@ -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 }} diff --git a/infra/helm/big-reference/templates/services.yaml b/infra/helm/big-reference/templates/services.yaml new file mode 100644 index 0000000..0891087 --- /dev/null +++ b/infra/helm/big-reference/templates/services.yaml @@ -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 }} diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml new file mode 100644 index 0000000..f2e0f55 --- /dev/null +++ b/infra/helm/big-reference/values.yaml @@ -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..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/: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 }] diff --git a/infra/helm/registry.yaml b/infra/helm/registry.yaml new file mode 100644 index 0000000..d39a655 --- /dev/null +++ b/infra/helm/registry.yaml @@ -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 diff --git a/infra/helm/seed-configmaps.sh b/infra/helm/seed-configmaps.sh new file mode 100755 index 0000000..9dd9961 --- /dev/null +++ b/infra/helm/seed-configmaps.sh @@ -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 + 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"