{{- $settings := fromYaml (include "keycloak.settings" .) }} {{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" }} {{- /* WHY THIS WORKLOAD HAS TWO INIT CONTAINERS, read before changing either of them (Gitea #83). Three measured facts force the shape: 1. db, health-enabled and metrics-enabled are BUILD options (verified in the 26.8.0 option table, where each carries the tool icon). The published image is augmented with their DEFAULTS - db=dev-file, health-enabled=false. 2. Supplying different build options at runtime makes Keycloak re-augment itself at start, rewriting four files in /opt/keycloak/lib/quarkus. kc.sh says so itself: "only exit code 10 means that implicit reaugmentation occurred and a relaunch is needed". Upstream's container guide calls that start what it is - it "creates an image that is mutable", which is exactly what readOnlyRootFilesystem: true forbids. And not setting db is no way out: THE PROBES ALONE force it, because health-enabled is a build option. 3. So the build has to happen before the serving container starts, into a volume. But an EMPTY emptyDir over /opt/keycloak/lib/quarkus does not work either, and this is the part that is not guessable: QuarkusEntryPoint - the main class kc.sh runs for EVERY command, `build` included - loads "quarkus/quarkus-application.dat" (the literal string is in QuarkusEntryPoint.class) before it dispatches anything. With that file hidden by a fresh mount, `kc.sh build` cannot start at all. Hence: SEED copies the image's own augmentation output into the volume (reading the real directory, writing only into the volume - so a read-only root filesystem is fine), BUILD then mounts the now-seeded volume at the real path and re-augments INTO it (again writing only into the volume), and the serving container mounts the finished volume and runs `start --optimized`. What that buys, and the reason it is worth two containers rather than one with readOnlyRootFilesystem: false: every container in this pod keeps the full hardening baseline. There is no documented deviation to carry, and no Trivy suppression that would also silence the serving container. */ -}} apiVersion: apps/v1 kind: Deployment metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment labels: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment spec: replicas: {{ include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas) }} selector: matchLabels: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment template: metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment labels: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment annotations: # NO checksum/config HERE, AND THAT IS NOT AN OVERSIGHT. This chart # renders no ConfigMap at all - Keycloak is configured entirely through # options and KC_* variables - so there is no file whose change could # fail to restart the workload. The rule (#82) has nothing to attach # to. values.yaml states this at the top, because an absent annotation # looks identical to a forgotten one. # # Hash of the rendered OnePasswordItems. This covers the SOURCE of the # secrets, not their content: repointing secrets.databaseSecretRef or # secrets.bootstrapAdminSecretRef at a different vault item rolls the # pods instead of leaving the old credentials in the running processes. # A change INSIDE an item cannot be covered - Helm never sees it (see # keycloak.secret.yaml) - and NOTES.txt names the manual restart. checksum/secret-ref: {{ include (print $.Template.BasePath "/keycloak.secret.yaml") . | sha256sum }} spec: automountServiceAccountToken: false securityContext: {{- include "keycloak.podSecurityContext" .Values.keycloak.securityContext | nindent 8 }} initContainers: # --- 1. SEED ------------------------------------------------------- # Copy the image's own augmentation output into the shared volume, so # that the BUILD container below can bootstrap at all (fact 3 in the # comment at the top of this file). # # It reads /opt/keycloak/lib/quarkus from the image and writes only # into the volume, which is why readOnlyRootFilesystem: true holds # here without any special handling. # # `command` is overridden because the image's entrypoint is kc.sh and # this step runs no Keycloak command - the only other chart in this # repo that overrides an entrypoint is convertx, and the same # obligation applies: this line has to be re-read whenever the app # version moves, because it depends on the image shipping a shell and # coreutils. Both are present (usr/bin/sh -> bash, usr/bin/cp, verified # in the image layer at 26.8.0). # # `cp -R` and not `cp -a`: -a preserves ownership, which a non-root # process cannot do - it would fail with a diagnostic and a non-zero # exit. Only the content and the directory structure matter here. - name: {{ .Release.Name }}-{{ .Chart.Name }}-seed image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent command: - /bin/sh - -c args: - | set -eu cp -R /opt/keycloak/lib/quarkus/. /quarkus-build/ ls -l /quarkus-build/ resources: {{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }} securityContext: {{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }} volumeMounts: - name: quarkus-build mountPath: /quarkus-build # --- 2. BUILD ------------------------------------------------------ # The Quarkus augmentation, i.e. `kc.sh build`. It writes into the # seeded volume, which is mounted at the real path here - so the root # filesystem stays read-only and the output survives into the serving # container. # # THE BUILD OPTIONS ARRIVE AS ENVIRONMENT VARIABLES, NOT AS CLI # ARGUMENTS, and that is a safety decision rather than a style one: # `command` is a fixed shell string here, and interpolating chart # values into a shell string is how a feature name with a space in it # becomes two arguments. As variables they are passed by the kubelet # and never parsed by a shell. `kc.sh build` reads KC_* exactly as it # reads flags. # # This is the ONE place build options exist in this chart. The serving # container passes `start --optimized` and no build option at all, so # there is no second list to drift out of step - and keycloak.env # refuses a build option's variable name outright (see _helpers.tpl). - name: {{ .Release.Name }}-{{ .Chart.Name }}-build image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent args: - build env: {{- include "keycloak.buildEnv" . | nindent 12 }} resources: {{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }} securityContext: {{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }} volumeMounts: - name: quarkus-build mountPath: {{ include "keycloak.quarkusBuildDir" . }} # Quarkus puts its temporary directory under the Keycloak home # rather than honouring java.io.tmpdir (upstream issue #37533), so # the augmentation needs both of these writable. - name: data mountPath: {{ include "keycloak.dataDir" . }} - name: tmp mountPath: /tmp containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment # Fully qualified: an unqualified reference would resolve to Docker # Hub by convention rather than by rule. quay.io is Keycloak's own # registry and the first quay.io image in this repository - which is # why ci/trivy/data/ksv0125.yaml had to learn about it. image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent # `start`, not `start-dev` - the development mode has insecure # defaults and a local cache. The image declares no CMD, only # ENTRYPOINT ["/opt/keycloak/bin/kc.sh"], so the subcommand has to # come from here. # # --optimized says "this installation is already built, do not check # and do not augment". It is what makes the read-only root filesystem # possible, and it is also why NO build option may be passed to this # container: with --optimized a build option is an error, and without # it the container would rebuild on a path it cannot write. args: - start - --optimized env: # THE HOSTNAME, DERIVED AND NOT LEFT OPEN - this is the # security-relevant pair of this chart. Upstream on proxy-headers: # "Misconfiguration might leave the server exposed to security # vulnerabilities." The failure mode is Host header injection: with # a hostname resolved from request headers, an attacker controls the # URLs the server generates, which is how tokens and password # resets end up pointed at somebody else's host. # # Two things keep that shut. hostname-strict stays at its default # true, so nothing is taken from the request. And hostname is a # FULL URL rather than a bare host, which upstream requires as soon # as hostname-admin is in play: "If hostname-admin is configured, # hostname must be set to a URL (not just hostname). Otherwise # Keycloak would not know what is the correct frontend URL". - name: KC_HOSTNAME value: {{ include "keycloak.frontendUrl" . | quote }} {{- if $adminHostname }} # THE ADMIN HOSTNAME - and it is NOT an access boundary. It decides # which URLs the server generates for the administration console # (redirects, assets, the API address the console itself calls). # Upstream, verbatim: "Using the hostname-admin option does not # prevent accessing the Administration REST API endpoints via the # frontend URL specified by the hostname option. If you want to # restrict access to the Administration REST API, you need to do it # on the reverse proxy level." # # So /admin/realms/... stays reachable through ingress.domain. This # chart renders no deny rule for it: a path rule on /admin that has # not been tested against a running console breaks redirects and # asset loading, and a measure that only looks like one is worse # than none. The full argument is at ingress.admin in values.yaml. - name: KC_HOSTNAME_ADMIN value: {{ include "keycloak.adminUrl" . | quote }} {{- end }} # Behind the Ingress the TLS ends at the controller, so the pod # itself serves plain HTTP - which Keycloak only does when told to. - name: KC_HTTP_ENABLED value: "true" # xforwarded, because the ingress-nginx controller sets # X-Forwarded-* and not RFC 7239 Forwarded. This is not optional # decoration: without it Keycloak sees the controller's address and # scheme instead of the client's. - name: KC_PROXY_HEADERS value: "xforwarded" - name: KC_HTTP_PORT value: {{ $settings.ports.http | int64 | quote }} - name: KC_HTTP_MANAGEMENT_PORT value: {{ $settings.ports.management | int64 | quote }} # CLUSTERING. ispn is already the default for `start` in production # mode; it is set explicitly because it is the decision that makes # the second replica a cluster member rather than a second island, # and an explicit value cannot be lost to a change of upstream # defaults. # # cache-stack is deliberately NOT set: the default is jdbc-ping, # which discovers peers through the database that is there anyway. # The alternative, cache-stack=kubernetes with DNS_PING, is marked # "(deprecated)" upstream and would need a headless Service and a # jgroups.dns.query - none of which this chart renders. - name: KC_CACHE value: "ispn" {{- with $settings.cache.clusterName }} # Only when set. The trap it addresses: "clusters that share the # same database but should remain separate will merge unless they # have distinct cluster names". See values.yaml - the default is # upstream's ISPN on purpose. - name: KC_CACHE_EMBEDDED_CLUSTER_NAME value: {{ . | quote }} {{- end }} # A stable node name, from the pod name. Upstream's default is "a # random name ... generated on each start, which makes it difficult # to correlate metrics and logs across restarts" - and with two # replicas that correlation is the first thing anybody needs when # the cluster misbehaves. - name: KC_CACHE_EMBEDDED_NODE_NAME valueFrom: fieldRef: fieldPath: metadata.name # THE DATABASE. Note what is NOT here: KC_DB. The vendor is a build # option and is baked in by the init container above; setting it # here would make this container re-augment on a read-only path. - name: KC_DB_URL_HOST value: {{ required "A database host must be provided (keycloak.db.host)" $settings.db.host | quote }} - name: KC_DB_URL_PORT value: {{ $settings.db.port | int64 | quote }} - name: KC_DB_URL_DATABASE value: {{ required "A database name must be provided (keycloak.db.database)" $settings.db.database | quote }} - name: KC_DB_USERNAME valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: username # AS A VARIABLE, NOT A FILE, and that is forced: Keycloak 26 has no # _FILE indirection for an option's value - no KC_DB_PASSWORD_FILE # and no generic KC_