Files
helm-charts/keycloak/templates/_helpers.tpl
T
jklein f784f40fd5
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
Validate Helm Charts / Build keycloak (pull_request) Successful in 4s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 11s
Install-test / Install-test keycloak (pull_request) Successful in 2m23s
Validate Helm Charts / Install-test (pull_request) Successful in 2m23s
feat(keycloak): new chart for Keycloak (1.0.0+up26.8.0)
Two replicas with cache=ispn, clustered through Keycloak's default jdbc-ping
stack: discovery runs over the PostgreSQL that is there anyway, so no headless
Service and no JGroups wiring. Deployment rather than StatefulSet - Keycloak 26
keeps everything in the database, user sessions included.

The chart's defining property is that it has TWO INIT CONTAINERS and needs
both. db, health-enabled and metrics-enabled are Keycloak BUILD options
(verified in the 26.8.0 option table), the published image is augmented with
their defaults, and supplying different ones at runtime makes Keycloak rewrite
/opt/keycloak/lib/quarkus at start - which readOnlyRootFilesystem: true forbids
and which upstream describes as a start that "creates an image that is
mutable". Not setting db is no way out: the probes alone force it, because
health-enabled is a build option too. An empty emptyDir over that path fails as
well, because QuarkusEntryPoint loads quarkus/quarkus-application.dat before it
dispatches any command, `build` included. So one init container seeds the
directory into a volume, the next runs `kc.sh build` into it, and the serving
container runs `start --optimized` - every container keeping the full hardening
baseline, with no documented deviation to carry.

Build options therefore exist in exactly one place, and the asymmetry is
enforced rather than trusted: the serving container passes no build option at
all, and a build option's variable name in keycloak.env fails the render with a
message naming it.

Hostname handling is the security-relevant part. KC_HOSTNAME is derived from
ingress.domain as a full URL (which upstream requires alongside hostname-admin),
hostname-strict stays at its default true, and proxy-headers is set rather than
optional. The admin hostname is NOT presented as an access control: upstream
states that hostname-admin "does not prevent accessing the Administration REST
API endpoints via the frontend URL specified by the hostname option", so the
chart renders no /admin deny rule and values.yaml, NOTES.txt and the wiki all
say in the same words that restricting that API is reverse-proxy work.

The management port 9000 is a containerPort and a ClusterIP Service for
in-cluster scraping, and is in no Ingress: it serves /health/* and optionally
/metrics without authentication. Liveness uses /health/live and deliberately
not /health or /health/ready - those include the database, and a liveness probe
on them would restart the whole cluster for as long as the database is away.

Also in this change:
- First PodDisruptionBudget in this repository, which makes it a convention
  (README section 11): minAvailable as a value, and rendered only while the
  computed replica count is >= 2, because minAvailable: 1 with one replica
  blocks every node drain forever with a message that names the PDB rather
  than the replica count.
- First quay.io image, so quay.io joins the trusted registries in
  ci/trivy/data/ksv0125.yaml - configured rather than suppressed, so
  AVD-KSV-0125 keeps being able to say something.
- Renovate block with NO version constraint, which is the counter-case to
  garage and stalwart and is measured rather than assumed: docker versioning
  already excludes the -N rebuild tags, the floating minors and latest/nightly.
  Three dry-run fixtures are recorded at the block, and against the committed
  config 26.7.4 offers exactly 26.7.5 (patch) and 26.8.0 (minor).
- No automerge exception, by decision (Gitea #83): the global automerge applies,
  and the minor-upgrade procedure plus `kc.sh update-compatibility` is
  documented in NOTES.txt instead.
- Two upstream defaults deviated from, each with its reason at the value:
  db-pool-max-size 20 instead of 100 (two replicas at 100 ask a default
  PostgreSQL for twice its max_connections), and uid 1000 / gid 0 instead of
  this repo's 20000/1000, because the image declares keycloak:x:1000:0 and
  chowns its install tree to that pair.

Part of #83
2026-10-04 17:02:47 +00:00

532 lines
23 KiB
Smarty

{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
the chart's name as define prefix, because sharing it would mean a library
chart, and a dependency is admissible but expensive (README, guideline 14) -
this chart takes none. The reference implementation is
template-chart/templates/_helpers.tpl.
*/}}
{{- define "keycloak.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "keycloak.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "keycloak.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "keycloak.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "keycloak.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
*/}}
{{- define "keycloak.multiplyQuantity" -}}
{{- $quantity := include "keycloak.formatNumber" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%s%s" (include "keycloak.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation
(issue #157): the null-safe fallback helpers pass values through fromYaml,
which parses every number as float64, and Go's "%v" switches float64 to
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
Kubernetes quantity. Strings ("700Mi") are passed through untouched.
*/}}
{{- define "keycloak.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them
(issues #61, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
<string>. Output: YAML of the merged dict (consume it with fromYaml).
An override key written without children is YAML null, and Helm treats that
null as a deletion of the chart's default. This helper defines the opposite
semantics - an empty block means "chart defaults apply" - while an explicitly
set value still wins, including false and 0.
*/}}
{{- define "keycloak.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "keycloak.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
Returns YAML of dict "value" <the sub-block, may be nil>.
*/}}
{{- define "keycloak.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
THE EFFECTIVE KEYCLOAK SETTINGS - everything the Deployment, the Services, the
PodDisruptionBudget and NOTES.txt read out of .Values.keycloak, merged onto this
chart's defaults.
Why the whole settings block goes through the null-safe helper and not only the
three usual suspects (guideline 12.2: "the rule is about the shape, not about
those three block kinds"): every value below is dereferenced by a template, and
an erased one would not abort the render - it would render a containerPort with
no number, a `--db=` with an empty right-hand side, or a database host that is
the empty string. The first is rejected by the API server at apply time, the
second fails the BUILD inside the init container, and the third is a pod waiting
on a connection that cannot be made. All three arrive after the render looked
fine, which is exactly the class #99 is about. satisfactory shipped the same
shape with half its blocks null-safe and half not (Gitea #16), and the half that
was not took the Service's port with it.
The defaults here MUST stay in sync with values.yaml, which remains the
documented, user-facing place for them; this copy is only the fallback.
Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
*/}}
{{- define "keycloak.settings" -}}
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" .Values "key" "keycloak" "path" ".Values"))).value -}}
{{- $defaults := dict
"replicas" 2
"db" (dict
"vendor" "postgres"
"host" nil
"port" 5432
"database" "keycloak"
"pool" (dict "initialSize" 2 "minSize" 2 "maxSize" 20))
"bootstrapAdmin" (dict "enabled" true)
"metrics" (dict "enabled" false)
"features" (list)
"cache" (dict "clusterName" nil)
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}}
{{- end -}}
{{/*
Render the replica count for the Deployment.
Input: dict "scaleDown" <flag|nil> "replicas" <value|nil>.
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 - Helm's "default" would swallow 0, hence the kindIs "invalid"
nil check.
- a missing replicas value defaults to 2.
A NOTE SPECIFIC TO THIS CHART: the default is 2, not 1, and that is the point
of the chart rather than a tuning choice - an SSO outage takes every dependent
login with it. Two replicas are cheap here because node discovery runs over the
database that is there anyway (cache=ispn plus Keycloak's default jdbc-ping
stack). What two replicas cost is the rolling-update restriction: upstream
supports a rolling update only inside a minor line, so a minor jump becomes a
procedure instead of a restart. See NOTES.txt.
*/}}
{{- define "keycloak.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
2
{{- end -}}
{{- end -}}
{{/*
THE BUILD OPTIONS, IN ONE PLACE - and this helper being the ONLY place is what
keeps the chart startable (Gitea #83).
Keycloak separates BUILD options from runtime options. Verified against the
26.8.0 "All configuration" table, where a build option carries the tool icon:
db, health-enabled, metrics-enabled, features and features-disabled are build
options; db-url-host, db-username, db-password, the db-pool-* sizes, hostname,
hostname-admin, hostname-strict, proxy-headers, http-enabled, http-port,
http-management-port, cache, cache-stack and the cache-embedded-* options are
not.
The published image is augmented with the build options' DEFAULTS (db=dev-file,
health-enabled=false, metrics-enabled=false). Supplying different ones at
runtime makes Keycloak re-augment itself at start, and the augmentation
rewrites /opt/keycloak/lib/quarkus - inside the root filesystem, which
readOnlyRootFilesystem: true makes read-only. Upstream's container guide calls
that start what it is: it "creates an image that is mutable".
So the BUILD init container receives exactly these options and the serving
container runs `start --optimized` and passes NO build option at all. That
asymmetry is deliberate: with `--optimized` Keycloak expects a pre-built
installation and a build option handed to it alongside is an error, and - more
importantly - there is no second list that could drift out of step with this
one. If a build option must be added, it is added HERE and nowhere else.
THEY ARE RENDERED AS ENVIRONMENT VARIABLES, NOT AS CLI ARGUMENTS, and that is a
safety decision rather than a style one. `kc.sh build` reads KC_* exactly as it
reads flags, and as variables the values are handed over by the kubelet instead
of being parsed by a shell - so a feature name containing a space cannot become
two arguments, and nothing here has to be shell-quoted.
health-enabled is hard-coded true rather than exposed as a value: all three
probes address /health/* on the management port, so a release with it switched
off would have no working probe at all - the same defect teamspeak-server's
port name exists to prevent (guideline 12.5).
*/}}
{{- define "keycloak.buildEnv" -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
- name: KC_DB
value: {{ required "A database vendor must be provided (keycloak.db.vendor)" $settings.db.vendor | quote }}
- name: KC_HEALTH_ENABLED
value: "true"
- name: KC_METRICS_ENABLED
value: {{ $settings.metrics.enabled | toString | quote }}
{{- with $settings.features }}
- name: KC_FEATURES
value: {{ join "," . | quote }}
{{- end -}}
{{- end -}}
{{/*
The variable names of Keycloak BUILD options, used to refuse them in the
additive env block (see keycloak.env below).
Read off the 26.8.0 option table. It is not claimed to be exhaustive - upstream
can add a build option tomorrow and this list would not know - which is why it
is the SECOND line of defence and not the first. The first is structural: the
serving container is rendered with `start --optimized` and no build option, so
there is no list of options on it to disagree with the init container's. This
guard only stops somebody re-introducing one through values.
*/}}
{{- define "keycloak.buildOptionEnvNames" -}}
- KC_DB
- KC_DB_KIND
- KC_DB_DRIVER
- KC_HEALTH_ENABLED
- KC_HTTP_MANAGEMENT_HEALTH_ENABLED
- KC_METRICS_ENABLED
- KC_FEATURES
- KC_FEATURES_DISABLED
- KC_HTTP_RELATIVE_PATH
- KC_HTTP_MANAGEMENT_RELATIVE_PATH
- KC_HTTPS_CLIENT_AUTH
- KC_HTTPS_MANAGEMENT_CLIENT_AUTH
- KC_TRANSACTION_XA_ENABLED
- KC_LEGACY_OBSERVABILITY_INTERFACE
{{- end -}}
{{/*
The frontend URL (KC_HOSTNAME) and the admin URL (KC_HOSTNAME_ADMIN).
BOTH ARE FULL URLs, NOT BARE HOSTS, and that is upstream's requirement: "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
(incl. port etc.)", and both values are "verified that full URL is used, incl.
scheme and hostname". https:// is the only scheme this chart renders - the
Ingress always terminates TLS, so the browser-facing scheme is https even
though the pod itself serves plain HTTP.
ingress.domain is required HERE and not only in the Ingress template, because
this is the read that makes it required even with ingress.enabled: false
(guideline 12.1): Keycloak refuses to start in production mode without a
hostname, hostname-strict being true by default.
*/}}
{{- define "keycloak.frontendUrl" -}}
https://{{ required "A Domain/Host must be provided (ingress.domain) - Keycloak derives KC_HOSTNAME from it and refuses to start without a hostname, so it stays required even with ingress.enabled: false" .Values.ingress.domain }}
{{- end -}}
{{- define "keycloak.adminUrl" -}}
https://{{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" (((.Values.ingress | default dict).admin | default dict).domain) }}
{{- end -}}
{{/*
Is the admin hostname in play? Only then are KC_HOSTNAME_ADMIN and the second
Ingress rendered.
WHAT IT IS NOT: an access control. hostname-admin decides which URLs the server
GENERATES; upstream states plainly that it "does not prevent accessing the
Administration REST API endpoints via the frontend URL specified by the hostname
option" and that restricting that API is reverse-proxy work. The full reasoning
sits at ingress.admin.enabled in values.yaml, and the same sentence is in
NOTES.txt and on the wiki page - three places on purpose, because the value's
name suggests a boundary and the next reader will assume one.
*/}}
{{- define "keycloak.adminHostnameEnabled" -}}
{{- $ingress := .Values.ingress | default dict -}}
{{- $admin := $ingress.admin | default dict -}}
{{- if kindIs "invalid" $admin.enabled -}}
true
{{- else -}}
{{- $admin.enabled -}}
{{- end -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with values.yaml. They reproduce THIS
chart's defaults, not the repo standard - see the uid/gid note.
*/}}
{{- define "keycloak.podSecurityContext" -}}
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "pod" "path" "keycloak.securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 1000
"runAsGroup" 0
"fsGroup" 1000
"fsGroupChangePolicy" "Always"
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.pod") -}}
{{- end -}}
{{- define "keycloak.containerSecurityContext" -}}
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "container" "path" "keycloak.securityContext"))).value -}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.container") -}}
{{- end -}}
{{/*
LIVENESS: /health/live on the management port, and deliberately NOT /health or
/health/ready.
Guideline 12.3 in the one shape where upstream has already separated the
checks: /health/live reports whether this process is alive and contains no
database check, while /health/ready and the aggregate /health do. A liveness
probe on either of those restarts every pod in the cluster for as long as the
database is away - which cannot fix a database, and which turns a degraded login
service into no login service. Same lesson as zipline; here it is one path
away, which is also why the wrong path would look entirely plausible in a diff.
*/}}
{{- define "keycloak.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/health/live" "port" "management")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.livenessProbe") -}}
{{- end -}}
{{/*
READINESS: /health/ready - which DOES include the database, and must. A node
that cannot reach its database answers every login with an error, so no Service
should route to it. This is the other half of guideline 12.3: a dependency-free
liveness path is only correct when readiness really does hang on the
dependency.
*/}}
{{- define "keycloak.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/health/ready" "port" "management")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.readinessProbe") -}}
{{- end -}}
{{/*
STARTUP: /health/started, Keycloak's own "finished booting" endpoint. It gates
liveness and readiness, which is why neither carries an initialDelaySeconds.
Budget = periodSeconds * failureThreshold = 10 * 60 = 600s. What has to fit in
it is not only the JVM and Infinispan: on a FIRST install Keycloak creates its
whole schema through Liquibase, and with two replicas starting together the
second waits on the first's migration lock. Too short a budget there is a
CrashLoopBackOff that reads as an application fault and is really a stopwatch.
The Quarkus augmentation is NOT inside this budget - it runs in the init
container, which has no probe and no deadline. That is the second thing the
init container buys, after the read-only root filesystem.
*/}}
{{- define "keycloak.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/health/started" "port" "management")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 60 -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.startupProbe") -}}
{{- end -}}
{{/*
The effective resources blocks, fed into the resources helper above so that an
erased "resources:" still yields the chart's requests and computed limits.
Two blocks, because the init container does something different from the
serving container: a one-shot, CPU-bound Quarkus augmentation that is gone
before the serving container starts.
*/}}
{{- define "keycloak.effectiveResources" -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" "500m" "memory" "700Mi" "ephemeral-storage" "256Mi") -}}
{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.resources")) -}}
{{- include "keycloak.resources" $merged -}}
{{- end -}}
{{- define "keycloak.buildResources" -}}
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "resources" "path" "keycloak.build"))).value -}}
{{- $defaults := dict
"limitFactor" 3
"requests" (dict "cpu" "500m" "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.build.resources")) -}}
{{- include "keycloak.resources" $merged -}}
{{- end -}}
{{/*
The additive env block, rendered through tpl with the value, secretKeyRef and
configMapKeyRef forms (guideline 10).
ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does
not replace it. At install time both reach the pod and the later one wins; on an
EXISTING release the pair collapses to the chart's own value, because Kubernetes
merges env by name - and helm upgrade still reports success. So do not use this
block to override KC_HOSTNAME or the database variables; change the values that
produce them.
AND ONE CLASS OF ENTRY IS REFUSED OUTRIGHT: a BUILD option's variable name. A
build option in the serving container's environment disagrees with what the init
container built, and Keycloak answers a disagreement by re-augmenting - onto a
read-only path. The result is a crash loop whose cause is nowhere near the
error message, so the render fails here instead, naming the variable. This is a
`fail`, not a silent drop, for the reason guideline 12.6 gives: a value that
cannot be honoured belongs in an abort, not in an implicit branch.
*/}}
{{- define "keycloak.env" -}}
{{- $root := .root -}}
{{- $buildNames := fromYamlArray (include "keycloak.buildOptionEnvNames" .) -}}
{{- range $value := .env }}
{{- if has (upper ($value.name | toString)) $buildNames }}
{{- fail (printf "keycloak.env may not set %q: it is a Keycloak BUILD option, and a build option on the serving container disagrees with what the init container built - Keycloak then re-augments itself onto the read-only /opt/keycloak/lib/quarkus and crash-loops. Build options are set through the chart's own values (keycloak.db.vendor, keycloak.metrics.enabled, keycloak.features), which feed `kc.sh build` in the init container. See values.yaml, point 2." $value.name) }}
{{- end }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" or "true" renders
# unquoted as a YAML number/bool, and the API server then rejects the whole
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
# type string".
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
The three writable paths, defined once because two containers have to agree on
them.
/opt/keycloak/lib/quarkus is the one that matters and the one that is not
guessable: it is where the Quarkus augmentation writes, i.e. where `kc.sh
build` puts generated-bytecode.jar, transformed-bytecode.jar,
quarkus-application.dat and build-system.properties. Read out of the published
image's layer, not inferred. The init container writes it, the serving
container reads it, and because the directory is also the only place a
re-augmentation would write, mounting it writable is what lets the rest of the
root filesystem stay read-only.
/opt/keycloak/data is needed because Quarkus places its temporary directory
under the Keycloak home rather than honouring java.io.tmpdir (upstream issue
#37533). It is EMPTY in the image, so an emptyDir over it hides nothing.
/opt/keycloak/lib/quarkus is NOT empty in the image - which is exactly why it
is seeded by the init container rather than merely mounted; an unseeded
emptyDir there hides quarkus-application.dat, and QuarkusEntryPoint loads that
before any Keycloak code runs.
*/}}
{{- define "keycloak.quarkusBuildDir" -}}/opt/keycloak/lib/quarkus{{- end -}}
{{- define "keycloak.dataDir" -}}/opt/keycloak/data{{- end -}}