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
This commit is contained in:
jklein authored and Justin Klein committed 2026-10-04 18:00:20 +00:00
1 parent a23d4977c5
commit 4c1cff1744
19 files changed
+2563

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database,
external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}.
ENDPOINTS
{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }}
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
served on the public host and Keycloak generates its URLs for it
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }}
Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves
/health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
WITHOUT authentication. It has a ClusterIP Service
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD, so a missing or
misnamed field leaves the pod in CreateContainerConfigError - with no message
naming the field.
{{ .Values.secrets.databaseSecretRef | default "<secrets.databaseSecretRef NOT SET>" }}
username the PostgreSQL role
password that role's password
The role must be able to CREATE its schema: Keycloak builds and migrates
the whole schema itself on first start.
{{ if $settings.bootstrapAdmin.enabled }}
{{ .Values.secrets.bootstrapAdminSecretRef | default "<secrets.bootstrapAdminSecretRef NOT SET>" }}
username the TEMPORARY bootstrap administrator (upstream default name:
temp-admin)
password its password
{{ else }}
(no bootstrap admin item - keycloak.bootstrapAdmin.enabled is false, which is
the correct state once a permanent administrator exists)
{{ end }}
Both arrive as ENVIRONMENT VARIABLES, not as files, and that is forced:
Keycloak 26 has no _FILE form for an option's value. The price: the password
is in the container's process environment.
FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE
1. kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ $deploy }}
Ready means /health/ready answered 200, which means the database is
reachable and the schema is in place. The first start is the slow one: it
creates the entire schema through Liquibase, and with {{ $replicas }} replica(s)
the second waits on the first's migration lock.
2. Check that the cluster really formed - this is what the second replica is
for, and a silent failure here leaves two independent islands:
kubectl -n {{ .Release.Namespace }} logs deployment/{{ $deploy }} | grep -i "ISPN000094\|view accepted\|members"
Look for a view with {{ $replicas }} members. One member per pod means
jdbc-ping did not find the peer.
3. Log in at {{ if $adminHostname }}{{ include "keycloak.adminUrl" . }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
with the bootstrap credentials from the vault item above.
4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one.
The bootstrap account is TEMPORARY by design - upstream: "the account
should exist only for the duration necessary to perform operations needed
to gain permanent and more secure admin access. After that, the account
needs to be removed manually." This is different from Keycloak before 26,
where KEYCLOAK_ADMIN created a regular administrator and there was nothing
to clean up.
a) In the master realm, create a user, set a password, assign the
realm-admin / admin role.
b) Verify you can log in as that user.
c) Delete the temp-admin user.
d) helm upgrade with keycloak.bootstrapAdmin.enabled: false
5. Until step 4d, EVERY START LOGS a line about the initial administrator not
being created, because the master realm already exists and the bootstrap
path is skipped. It is harmless. It is also the kind of message somebody
eventually investigates, which is why 4d is part of the install and not a
tidy-up.
MINOR UPGRADES NEED THE CLUSTER DOWN - AND THE AUTOMERGE DOES NOT KNOW THAT
Keycloak supports a rolling update only WITHIN a minor line. A minor jump
(26.8 -> 26.9) requires every node shut down before any new node starts;
mixing two minor versions in one Infinispan cluster is not a supported
configuration, and nothing in the cluster reports that it happened.
THIS REPOSITORY'S RENOVATE AUTOMERGES EVERY UPDATE TYPE, MAJORS INCLUDED.
That is a deliberate, accepted risk and not an oversight (Gitea #83), but it
means an app-version PR that moves the minor can reach main, publish the
chart and be rolled out as a rolling update without anybody deciding to.
Watch app-version PRs for a changed minor, and if one has landed, take this
path rather than letting the rollout happen:
1. Decide it with the tool instead of the version number. With the OLD
version and the OLD configuration:
kc.sh update-compatibility metadata --file=/tmp/kc-compat.json
then with the NEW version and the NEW configuration:
kc.sh update-compatibility check --file=/tmp/kc-compat.json
Pass EVERY configuration option on both calls - upstream: "Omitting any
configuration options results in incomplete metadata, and could lead to
a wrong reported result".
Exit codes, which is what to automate on: 0 rolling update possible,
1 unexpected error, 2 invalid CLI option, 3 and 4 not possible - the
deployment must be shut down.
2. If it says shut down:
helm upgrade ... --set scaleDown=true # every pod stops
# back up the database AND the configuration before anything else
helm upgrade ... --set scaleDown=false # with the new version
A NOTE ON WHAT IS NOT SETTLED: upstream's 26.6 release notes call patch-level
rolling updates supported and active by default, while the 26.8.0 page for
update-compatibility says a rolling update is possible "when the Keycloak
version is the same" and uses a PATCH pair (26.2.0 -> 26.2.1) as its example
of an incompatible one. Those two cannot both be current. Until it is clear,
run the check for patch updates too and trust its exit code rather than the
size of the version step.
TRAPS WORTH KNOWING BEFORE YOU NEED THEM
- THE ADMIN HOSTNAME IS NOT AN ACCESS CONTROL. hostname-admin decides which
URLs the server generates, nothing more. Upstream: "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/... is reachable through the
public host whatever you put on the admin host, and an IP allow-list on the
admin Ingress restricts nothing on its own. This chart renders no deny rule
for /admin - see values.yaml for why not.
- A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the
database, so - upstream - "clusters that share the same database but should
remain separate will merge unless they have distinct cluster names. The
default cluster name is ISPN." Two releases of this chart on one database
therefore form ONE cluster, silently.
{{ with $settings.cache.clusterName }}This release sets cache-embedded-cluster-name={{ . }}.{{ else }}This release uses the default name (ISPN) - keycloak.cache.clusterName is
unset. Read its comment in values.yaml before setting it: upstream also
warns about cache invalidation between differently-named clusters.{{ end }}
- THERE IS NO ConfigMap IN THIS CHART, so no checksum/config annotation. That
is not an omission: Keycloak is configured entirely through options and
KC_* variables, so there is no config file whose change could fail to
restart anything.
- A CHANGE INSIDE A VAULT ITEM RESTARTS NOTHING. Helm never sees it. After
rotating a field:
kubectl -n {{ .Release.Namespace }} rollout restart deployment/{{ $deploy }}
(Repointing a secrets.*SecretRef at a different item DOES roll the pods -
that is covered by checksum/secret-ref.)
- THE POD HAS TWO INIT CONTAINERS AND NEEDS BOTH. db, health-enabled and
metrics-enabled are Keycloak BUILD options, the published image is built
with their defaults, and a runtime change makes Keycloak rewrite
/opt/keycloak/lib/quarkus at start - which readOnlyRootFilesystem: true
forbids. So one init container seeds that directory into a volume and the
next runs `kc.sh build` into it; the serving container then runs
`start --optimized`. If a pod is stuck in Init, read those logs first:
kubectl -n {{ .Release.Namespace }} logs <pod> -c {{ .Release.Name }}-{{ .Chart.Name }}-build
Build options are set ONLY through keycloak.db.vendor,
keycloak.metrics.enabled and keycloak.features. Putting one into
keycloak.env fails the render on purpose.
- POOL SIZES ARE PER REPLICA. keycloak.db.pool.maxSize is {{ $settings.db.pool.maxSize | int64 }}, so with the
current {{ $replicas }} replica(s) this release can open up to {{ mul ($settings.db.pool.maxSize | int64) $replicas }} connections.
Upstream's default is 100 per node, which with two nodes exceeds a default
PostgreSQL's max_connections of 100. Raise it together with the server,
never alone.
{{ if and $settings.podDisruptionBudget.enabled (ge $replicas 2) }}
- A PodDisruptionBudget IS in place ({{ .Release.Name }}-{{ .Chart.Name }}-pdb, minAvailable
{{ $settings.podDisruptionBudget.minAvailable | int64 }}), so a node drain cannot take all replicas at once.
{{ else if $settings.podDisruptionBudget.enabled }}
- NO PodDisruptionBudget was rendered, because the effective replica count is
{{ $replicas }}. That is deliberate: minAvailable 1 with a single replica forbids every
voluntary eviction, so `kubectl drain` would block forever on a pod nothing
may move - and the error would name the PDB rather than the replica count.
Run two replicas to get the budget.
{{ else }}
- NO PodDisruptionBudget (keycloak.podDisruptionBudget.enabled is false). A
node drain may take every replica at once.
{{ end }}
WHAT THIS CHART DOES NOT RENDER
- No PVC and no StatefulSet: Keycloak 26 keeps everything in the database,
user sessions included (persistent sessions are the default; volatile ones
are deprecated as of 26.8). There is no disk state to keep.
- No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
replicas, or the cluster silently becomes two islands.
- No deny rule for /admin on the public Ingress, and no restriction of the
administration REST API. That is reverse-proxy work - see the first trap.
+531
View File
@@ -0,0 +1,531 @@
{{/*
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 -}}
+373
View File
@@ -0,0 +1,373 @@
{{- $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_<OPTION>_FILE. Checked against the 26.8.0
# option table, where every KC_*_FILE variable is a path option
# (certificates, key stores, trust stores, log and export files).
# The price is named at secrets.databaseSecretRef in values.yaml.
- name: KC_DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
key: password
# POOL SIZES, per replica. Upstream's db-pool-max-size default is
# 100, which with two replicas asks a default PostgreSQL
# (max_connections = 100) for twice what it has. See values.yaml.
- name: KC_DB_POOL_INITIAL_SIZE
value: {{ $settings.db.pool.initialSize | int64 | quote }}
- name: KC_DB_POOL_MIN_SIZE
value: {{ $settings.db.pool.minSize | int64 | quote }}
- name: KC_DB_POOL_MAX_SIZE
value: {{ $settings.db.pool.maxSize | int64 | quote }}
{{- if $settings.bootstrapAdmin.enabled }}
# THE TEMPORARY BOOTSTRAP ADMINISTRATOR. Needed for a first
# install, because Keycloak "only allows to create the initial
# admin user from a local network connection", which a container
# never has.
#
# Two things NOTES.txt repeats because they cost time otherwise:
# these variable names exist only since Keycloak 26 (before:
# KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD), and the account they
# create is TEMPORARY - creating a permanent administrator
# afterwards is a mandatory step, not advice.
- name: KC_BOOTSTRAP_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
key: username
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
key: password
{{- end }}
{{- with .Values.keycloak.env }}
{{- include "keycloak.env" (dict "root" $ "env" .) | nindent 12 }}
{{- end }}
ports:
- containerPort: {{ $settings.ports.http | int64 }}
protocol: TCP
name: http
# THE MANAGEMENT PORT. All three probes address it BY NAME, so
# renaming it here breaks them. It serves /health/* and - with
# keycloak.metrics.enabled - /metrics, both UNAUTHENTICATED, which
# is why no Ingress in this chart points at it and none should be
# added.
- containerPort: {{ $settings.ports.management | int64 }}
protocol: TCP
name: management
# JGroups. Declared for the record rather than for routing: nothing
# serves it and no Service exposes it. jdbc-ping finds the peers
# through the database and then opens DIRECT pod-to-pod
# connections on this port, so the replicas need plain L3
# reachability between pod IPs - no Service, headless or otherwise.
- containerPort: {{ $settings.ports.jgroups | int64 }}
protocol: TCP
name: jgroups
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
#
# Which probe points where, and why liveness must NOT use /health or
# /health/ready, is argued at each helper in _helpers.tpl.
livenessProbe:
{{- include "keycloak.livenessProbe" .Values.keycloak.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "keycloak.readinessProbe" .Values.keycloak.readinessProbe | nindent 12 }}
startupProbe:
{{- include "keycloak.startupProbe" .Values.keycloak.startupProbe | nindent 12 }}
resources:
{{- include "keycloak.effectiveResources" .Values.keycloak.resources | nindent 12 }}
securityContext:
{{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }}
volumeMounts:
# The finished augmentation, produced by the two init containers.
# Writable, although --optimized means nothing should write here:
# it is an emptyDir either way, and a writable mount is what keeps
# a re-augmentation (should a future version decide it needs one) a
# slow start rather than a crash loop.
- name: quarkus-build
mountPath: {{ include "keycloak.quarkusBuildDir" . }}
# Empty in the image, so this hides nothing. Needed because Quarkus
# places its temporary directory under the Keycloak home instead of
# honouring java.io.tmpdir (upstream issue #37533).
- name: data
mountPath: {{ include "keycloak.dataDir" . }}
- name: tmp
mountPath: /tmp
# DELIBERATELY NOT MOUNTED: /opt/keycloak/conf. It is not empty in
# the image - it carries keycloak.conf and, more importantly,
# cache-ispn.xml, which is the Infinispan configuration cache=ispn
# reads. An emptyDir there would hide it and the cluster would come up
# with no cache configuration.
restartPolicy: Always
volumes:
- name: quarkus-build
emptyDir: {}
- name: data
emptyDir: {}
- name: tmp
emptyDir: {}
+108
View File
@@ -0,0 +1,108 @@
{{- /*
TWO INGRESS OBJECTS, AND THE SECOND ONE IS NOT A SECURITY BOUNDARY.
The public one publishes the login endpoints, the token endpoints and the
account console. The admin one publishes the SAME backend under a second
hostname, so that the administration console - whose URLs Keycloak generates
from hostname-admin - can live on an internal ingress class or in a different
DNS zone.
WHAT IT DOES NOT DO, because the value's name invites the opposite assumption.
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, and an IP allow-list on the admin host
restricts nothing by itself.
This chart deliberately renders NO deny rule for /admin on the public Ingress.
Two reasons, and the second is the one that decided it: the administration
console loads assets and performs redirects across more paths than /admin, so a
rule written from the path name breaks the console in ways that only show up in
a browser; and a rule nobody has tested against a running console is a measure
that looks like one, which is worse than an absent one. Whoever needs the
restriction puts it on the proxy in front of the public host, where it can be
tested.
GUIDELINE 12.1, CHECKED IN BOTH DIRECTIONS: ingress.domain is read a second
time outside this template - as KC_HOSTNAME on the Deployment - so it stays
required even with ingress.enabled: false, and its required() lives in the
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
this file, so switching both Ingresses off frees it.
*/ -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
http:
paths:
- backend:
service:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
{{- if $adminEnabled }}
{{- if .Values.ingress.enabled }}
---
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Its own certificate, for its own host. A separate TLS secret
# (tls-<release>-keycloak-admin-ingress) rather than a shared one, because
# these are two different hostnames that may well sit on two different
# ingress classes - the shared-secret form in the style guide is for
# several objects on ONE host.
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
{{- with ((.Values.ingress.admin | default dict).annotations) }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
# Falls back to ingress.className when not set. An internal ingress class
# here is the useful half of the admin hostname - and still not the access
# control the comment at the top of this file is about.
ingressClassName: {{ (.Values.ingress.admin | default dict).className | default .Values.ingress.className }}
rules:
- host: {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
http:
paths:
# The SAME backend and the same path as the public Ingress. The
# administration console is not a separate application and not a
# separate path - only the hostname differs, and Keycloak generates
# its admin URLs for that hostname because of KC_HOSTNAME_ADMIN.
- backend:
service:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
{{- end }}
@@ -0,0 +1,40 @@
{{- $settings := fromYaml (include "keycloak.settings" .) }}
{{- /*
THE MANAGEMENT SERVICE, AND WHAT IT IS AND IS NOT FOR.
Keycloak serves /health, /health/live, /health/ready, /health/started and -
with keycloak.metrics.enabled - /metrics on the management port, and it serves
them WITHOUT AUTHENTICATION. The probes do not need this Service (the kubelet
addresses the container port directly); a metrics scraper or an uptime check
inside the cluster does, which is the only reason it exists.
IT IS A SEPARATE OBJECT FROM THE SERVING SERVICE ON PURPOSE. Putting port 9000
next to port 8080 on one Service would mean the Service an Ingress already
points at also carries the unauthenticated health and metrics surface - and
then publishing it is one wrong port name in an Ingress backend away, with
nothing in the diff that reads as a mistake. Separated, naming it is a
deliberate act.
AND IT MUST NEVER GET AN INGRESS. There is no value in this chart that would
publish it, and none should be added. An unauthenticated readiness endpoint on
the internet tells an attacker when the database is down; /metrics tells them
the realm and client names. Upstream's own alternative for this - setting
legacy-observability-interface=true, which moves health back onto the serving
port where the Ingress already is - is explicitly "not recommended for security
reasons", and this chart does not offer it.
*/ -}}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-management-service
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
ports:
- protocol: TCP
port: {{ $settings.ports.management | int64 }}
targetPort: management
name: management
type: ClusterIP
+38
View File
@@ -0,0 +1,38 @@
{{- $settings := fromYaml (include "keycloak.settings" .) }}
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) }}
{{- /*
THE FIRST PodDisruptionBudget IN THIS REPOSITORY, and the convention it sets is
in README ("Pod disruption budgets") and in the wiki style guide.
WHY IT EXISTS HERE: with two replicas, a node drain is otherwise free to take
both pods at once - and a drain is the most common reason a pod moves, because
it is what a node upgrade does. Without this object the second replica buys
nothing against the failure it was added for. minAvailable rather than
maxUnavailable, because the guarantee wanted is about the service: at least one
node answers logins throughout the drain.
AND WHY IT IS NOT RENDERED BELOW TWO REPLICAS - the trap that makes a PDB worth
a condition rather than an unconditional object: minAvailable: 1 with exactly
ONE replica forbids every voluntary eviction, so `kubectl drain` blocks forever
on a pod nothing is allowed to move. The message then names the PDB and not the
replica count, which sends the reader to the wrong file. The same applies to
scaleDown: true, where there are no pods at all and the object would only
describe a promise about nothing.
So: rendered when the effective replica count is >= 2, skipped otherwise, and
NOTES.txt prints which of the two happened so that an operator does not have to
guess from `kubectl get pdb`.
*/ -}}
{{- if and $settings.podDisruptionBudget.enabled (ge $replicas 2) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-pdb
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
minAvailable: {{ $settings.podDisruptionBudget.minAvailable | int64 }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
{{- end }}
+84
View File
@@ -0,0 +1,84 @@
{{- /*
The 1Password items this chart materializes. Two of them, because they have
different lifetimes rather than different audiences: the database credential
lives as long as the release, while the bootstrap administrator is meant to be
switched off again after the first install.
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
materializes one Secret key per FIELD of the item, and the Deployment
references those keys by name. An item without the expected field leaves the
container in CreateContainerConfigError - not in a crash loop, and not with a
message about the field. NOTES.txt lists all four fields for the installer.
THEY ARRIVE AS ENVIRONMENT VARIABLES AND THAT IS FORCED, NOT PREFERRED.
Keycloak 26 has no _FILE indirection for an option's value: there is no
KC_DB_PASSWORD_FILE and no generic KC_<OPTION>_FILE. Checked against the 26.8.0
option table - every KC_*_FILE variable there is a PATH option (certificate,
key store, trust store, log file, export file), i.e. "point at this file", not
"read this option's value from a file". So the file form valkey argues for is
simply unavailable here. DO NOT "FIX" THIS to a file mount: there is no option
that would read it. (Keycloak's Vault SPI, KC_VAULT=file, is a different and
larger mechanism with its own naming scheme, and adopting it would be its own
decision.)
AND THE LIMIT THAT CANNOT BE CLOSED: a change to the CONTENT of either item
restarts nothing. Helm never sees a secret the 1Password operator
materializes, so there is nothing to hash. What this chart does cover is the
op:// PATH, through checksum/secret-ref on the pod template. After rotating a
value INSIDE an item the manual step is
kubectl -n <ns> rollout restart deployment/<release>-keycloak-deployment
The 1Password operator's auto-restart annotation is NOT set here. Unlike the
StatefulSet charts in this repo it WOULD work - the operator enumerates
Deployments - so this is a decision rather than a constraint: its trigger is
the item's version COUNTER and not its content, so a re-save with identical
values rolls the whole cluster. For an identity provider, an unannounced
rolling restart because somebody opened a vault entry is worse than a
deliberate one. README guideline 9 has the precedence trap if it is ever set.
*/ -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
# Must carry the fields: username, password
#
# The role needs to own its schema: Keycloak creates and migrates the whole
# schema itself through Liquibase on first start, so a read-write grant on
# existing tables is not enough.
#
# Host, port and database name are NOT in this item - they are plain values
# (keycloak.db.host / .port / .database), because they are not secret and
# because moving a database should not mean editing a vault entry.
itemPath: {{ required "You must provide a database secret reference (secrets.databaseSecretRef) - the 1Password item holding the fields 'username' and 'password' of the PostgreSQL role Keycloak uses" .Values.secrets.databaseSecretRef }}
{{- if $settings.bootstrapAdmin.enabled }}
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
# Must carry the fields: username, password
#
# THIS ACCOUNT IS TEMPORARY, and that is a change from Keycloak before 26 -
# the old KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD pair created a regular
# administrator. Upstream on the current pair: "A user or service admin
# account created using one of the methods described below is temporary ...
# the account should exist only for the duration necessary to perform
# operations needed to gain permanent and more secure admin access. After
# that, the account needs to be removed manually."
#
# So creating a permanent administrator after the first install is a
# MANDATORY step, and switching keycloak.bootstrapAdmin.enabled to false
# afterwards removes both this item and the per-start log line about the
# initial administrator not being created. NOTES.txt has the sequence.
#
# Upstream's default for the username is "temp-admin"; this chart takes both
# fields from the vault so the pair has one source of truth.
itemPath: {{ required "You must provide a bootstrap admin secret reference (secrets.bootstrapAdminSecretRef) - the 1Password item holding the fields 'username' and 'password' of the TEMPORARY bootstrap administrator. Required while keycloak.bootstrapAdmin.enabled is true; set that to false once a permanent administrator exists" .Values.secrets.bootstrapAdminSecretRef }}
{{- end }}
+30
View File
@@ -0,0 +1,30 @@
{{- $settings := fromYaml (include "keycloak.settings" .) }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
ports:
# The serving port. Both Ingresses target it BY NAME ("http"), and
# in-cluster consumers reach it at
# http://<release>-keycloak-service.<namespace>.svc.cluster.local:8080
- protocol: TCP
port: {{ $settings.ports.http | int64 }}
targetPort: http
name: http
# DELIBERATELY NO MANAGEMENT PORT AND NO JGROUPS PORT HERE. The management
# port has a Service of its own (keycloak.management.service.yaml) so that
# whatever scrapes it has to name it explicitly rather than inherit it from
# the Service an Ingress points at - mixing the two is how an
# unauthenticated health endpoint ends up one annotation away from the
# internet. JGroups needs no Service at all: jdbc-ping discovers peers
# through the database and the nodes then talk pod IP to pod IP.
#
# ClusterIP, and no value to change it. The way out of the cluster is the
# Ingress with a certificate, not a NodePort that puts an identity provider
# on a routable address by accident.
type: ClusterIP