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:
1 parent
a23d4977c5
commit
4c1cff1744
19 files changed
+2563
No files matched your search
@@ -0,0 +1,50 @@
|
||||
apiVersion: v2
|
||||
name: keycloak
|
||||
description: A Helm chart to deploy Keycloak, an OpenID Connect and SAML identity provider, as a two-node cluster on Kubernetes
|
||||
icon: https://www.keycloak.org/resources/images/icon.svg
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag of quay.io/keycloak/keycloak. No "v"
|
||||
# prefix here - upstream tags are plain three-part versions (26.8.0).
|
||||
#
|
||||
# THE TAG LANDSCAPE, AND WHY THIS CHART NEEDS NO VERSION CONSTRAINT. Measured
|
||||
# on 2026-10-04 against quay.io's tag API, this registry carries four shapes
|
||||
# side by side:
|
||||
#
|
||||
# 1. release tags 26.8.0, 26.7.5, ... <- what we track
|
||||
# 2. REBUILD tags 26.8.0-0, 26.7.3-1, 26.7.2-2 - the same app
|
||||
# version rebuilt (base image or CVE fix) without an app-version bump.
|
||||
# 26.8.0-0 resolves to the SAME manifest digest as 26.8.0.
|
||||
# 3. floating minor tags 26.8, 26.7, 26.6, ... - they move
|
||||
# 4. floating names latest, nightly - they move daily
|
||||
#
|
||||
# This is the same GENUS of trap as garage's commit-SHA tags and stalwart's
|
||||
# moving names, and it is the one case in this repo where the answer came out
|
||||
# the other way: plain `docker` versioning already excludes all three unwanted
|
||||
# shapes, so the renovate.json5 block carries NO versioningTemplate. Docker
|
||||
# versioning splits a tag at the first dash and only ever offers candidates
|
||||
# with the SAME suffix - our value has none, so the -N rebuilds are invisible
|
||||
# to it - and 26.8 fails on the missing third part while latest/nightly parse
|
||||
# as no version at all.
|
||||
#
|
||||
# THAT IS MEASURED, NOT ASSUMED, and the measurement is written down because
|
||||
# the next round will otherwise add a constraint "to be safe" (renovate 44.132.5,
|
||||
# --platform=local --dry-run=lookup against the real registry):
|
||||
# appVersion 26.8.0 -> updates [], warnings []
|
||||
# appVersion 26.7.0 -> exactly one: 26.8.0 (minor)
|
||||
# appVersion 26.7.4 -> exactly two: 26.7.5 (patch), 26.8.0 (minor)
|
||||
# No run offered a rebuild tag, a floating minor, latest or nightly.
|
||||
#
|
||||
# AND THE OTHER HALF, WHICH THE VERSION NUMBER CANNOT CARRY: Keycloak supports
|
||||
# a rolling update only WITHIN a minor line. A minor jump needs the cluster
|
||||
# shut down. The global `automerge: true` in renovate.json5 applies here
|
||||
# anyway - a deliberate acceptance of that risk, not an oversight - so the
|
||||
# procedure lives in NOTES.txt and on the wiki page instead of in a
|
||||
# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
|
||||
# merging an app-version PR that changes the minor.
|
||||
version: 1.0.0+up26.8.0
|
||||
appVersion: "26.8.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -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.
|
||||
@@ -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 -}}
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "keycloak values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.databaseSecretRef, keycloak.db.host, ingress.domain and ingress.admin.domain all ship as null and JSON Schema's 'required' tests presence, not null-ness, so a 'required' array would never fire - the enforcement is required() in the templates (issue #18).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"global": {
|
||||
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||
"type": "object"
|
||||
},
|
||||
"scaleDown": {
|
||||
"description": "true renders the Deployment with replicas: 0 and takes precedence over keycloak.replicas. Also the first half of the minor-upgrade procedure, because Keycloak supports a rolling update only inside a minor line.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"databaseSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the PostgreSQL role, which has to be able to create its own schema. Always required - the chart has no default database.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"bootstrapAdminSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the TEMPORARY bootstrap administrator. Required while keycloak.bootstrapAdmin.enabled is true.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"keycloak": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "Default 2, because an SSO outage takes every dependent login with it. An explicit 0 is allowed and is the per-workload way to stop the release. Above 1 the rolling-update restriction applies: a minor version jump needs the whole cluster down.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"db": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults. The host has no default and is enforced by required() in the Deployment.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"description": "A Keycloak BUILD option - it is passed to `kc.sh build` in the init container, never to the serving container. dev-file and dev-mem are rejected on purpose: they are Keycloak's development defaults, and an identity provider on an embedded database works until the pod moves. Only 'postgres' is exercised by this chart's CI.",
|
||||
"type": "string",
|
||||
"enum": ["postgres", "mariadb", "mysql", "mssql", "oracle"]
|
||||
},
|
||||
"host": {
|
||||
"description": "Host of the external PostgreSQL. Required - enforced by required() in the Deployment, because a null here would render an empty KC_DB_URL_HOST and a pod that waits on a connection it can never make.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"port": { "$ref": "#/definitions/port" },
|
||||
"database": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"description": "Connection pool sizes, PER REPLICA. Upstream's db-pool-max-size default is 100; this chart ships 20, because two replicas at 100 ask a default PostgreSQL (max_connections = 100) for twice what it has.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"initialSize": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"minSize": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"maxSize": { "type": ["integer", "null"], "minimum": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapAdmin": {
|
||||
"description": "The temporary bootstrap administrator. Switch this off once a permanent administrator exists - it also stops the per-start log line about the initial administrator not being created.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"description": "A Keycloak BUILD option (metrics-enabled). Off, because the management port is unauthenticated in this chart's configuration and metrics add realm and client names to that surface.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"description": "Additional Keycloak feature flags (--features), a BUILD option. A list rather than a comma-separated string, so two entries cannot arrive as one unknown feature name - which Keycloak rejects at build time, inside the init container. The pattern keeps a value out that could not be a feature name.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]*$"
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"clusterName": {
|
||||
"description": "--cache-embedded-cluster-name. null means Keycloak's default, 'ISPN'. Read the comment in values.yaml before setting it: two releases sharing one database MERGE into one cluster unless the names differ, but upstream also warns that cache invalidation does not work between differently-named clusters.",
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"ports": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults. The Deployment renders these as containerPorts and the Services address them by name, so both follow one source.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"http": { "$ref": "#/definitions/port" },
|
||||
"management": {
|
||||
"description": "The management port. It carries the unauthenticated /health/* endpoints and optionally /metrics, and all three probes address it by name. It is deliberately in no Ingress.",
|
||||
"$ref": "#/definitions/port"
|
||||
},
|
||||
"jgroups": {
|
||||
"description": "JGroups transport port. Declared as a containerPort for the record; jdbc-ping opens direct pod-to-pod connections on it.",
|
||||
"$ref": "#/definitions/port"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"description": "Resources of the SERVING container. The memory request is not only about scheduling here: the image sizes the JVM heap relative to the container limit (-XX:MaxRAMPercentage=70), so request x limitFactor decides how much memory the application believes it has.",
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"build": {
|
||||
"description": "The build init container, which runs the one-shot Quarkus augmentation. Its resources are transient - it is gone before the serving container starts - but they count for scheduling, because a pod's effective request is the maximum of its init containers' and the sum of its containers'.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resources": { "$ref": "#/definitions/resources" }
|
||||
}
|
||||
},
|
||||
"livenessProbe": { "$ref": "#/definitions/probe" },
|
||||
"readinessProbe": { "$ref": "#/definitions/probe" },
|
||||
"startupProbe": { "$ref": "#/definitions/probe" },
|
||||
"securityContext": { "$ref": "#/definitions/securityContext" },
|
||||
"podDisruptionBudget": {
|
||||
"description": "The first PodDisruptionBudget in this repository. It is rendered only while the effective replica count is >= 2: minAvailable 1 with a single replica forbids every voluntary eviction, so a node drain would block forever on a pod nothing may move.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"minAvailable": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"env": { "$ref": "#/definitions/env" }
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the public Ingress object only (issue #93). Checked in both directions for this chart: ingress.domain is ALSO read as KC_HOSTNAME on the Deployment, so switching the Ingress off frees clusterIssuer but NOT domain - Keycloak refuses to start in production mode without a hostname.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": { "type": ["string", "null"] },
|
||||
"domain": {
|
||||
"description": "The public hostname, a plain host - the chart renders it as the full URL https://<domain> for KC_HOSTNAME, which upstream requires as soon as hostname-admin is in play. Required even with enabled: false.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": { "type": ["string", "null"] },
|
||||
"annotations": {
|
||||
"description": "Extra annotations for the public Ingress, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
},
|
||||
"admin": {
|
||||
"description": "The administration console on a hostname of its own. NOT AN ACCESS CONTROL: hostname-admin decides which URLs the server generates; upstream states that it does not prevent reaching the Administration REST API through the public hostname, and that restricting that API is reverse-proxy work. The chart renders no deny rule for /admin.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"domain": {
|
||||
"description": "The admin hostname, a plain host - rendered as https://<domain> for KC_HOSTNAME_ADMIN. Required while enabled.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"description": "null falls back to ingress.className. An internal ingress class here keeps the console off the public controller, which is the useful half of the admin hostname.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"port": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limitFactor": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"requests": { "$ref": "#/definitions/resourceList" },
|
||||
"limits": { "$ref": "#/definitions/resourceList" }
|
||||
}
|
||||
},
|
||||
"resourceList": {
|
||||
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cpu": { "type": ["string", "number", "null"] },
|
||||
"memory": { "type": ["string", "number", "null"] },
|
||||
"ephemeral-storage": { "type": ["string", "number", "null"] },
|
||||
"ephemeralStorage": { "type": ["string", "number", "null"] }
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"description": "Additive container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success. A BUILD option's variable name (KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED, KC_FEATURES, ...) is refused by the template with a fail: on the serving container it would disagree with what the init container built, and Keycloak answers that by re-augmenting onto a read-only path.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" },
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"secretKeyRef": { "$ref": "#/definitions/keyRef" },
|
||||
"configMapKeyRef": { "$ref": "#/definitions/keyRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"key": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, which deviate from the repo's usual 20000/1000 pair - the published image declares keycloak:x:1000:0 in /etc/passwd, puts that user in the ROOT GROUP and copies the whole install tree with --chown=1000:0, so uid 1000 / gid 0 is what the image expects and what makes the mounted build volume writable. Every container in the pod, both init containers included, uses the same container context: readOnlyRootFilesystem: true holds throughout, which is the reason the chart has two init containers rather than one writable one.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pod": {
|
||||
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"container": {
|
||||
"description": "Merged onto the chart's container hardening defaults and rendered as the securityContext of every container in the pod; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"probe": {
|
||||
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults carry the probe's CONTEXT: the port NAME 'management' for all three, and three DIFFERENT paths - /health/live for liveness (no database check), /health/ready for readiness (with one), /health/started for startup. Pointing liveness at /health or /health/ready would restart every pod of the cluster for as long as the database is away.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
# Keycloak - an OpenID Connect and SAML identity provider, deployed as a
|
||||
# TWO-NODE cluster against an external PostgreSQL.
|
||||
#
|
||||
# READ THIS FIRST, because three properties shape every value below and none of
|
||||
# them is obvious from the outside (Gitea #83).
|
||||
#
|
||||
# 1. THERE IS NO ConfigMap IN THIS CHART, AND THAT IS NOT AN OMISSION.
|
||||
# Keycloak is configured entirely through options - CLI arguments and KC_*
|
||||
# environment variables - so no file is rendered, mounted or hashed. The
|
||||
# repo's checksum/config rule (#82) therefore has nothing to attach to:
|
||||
# there is no ConfigMap whose change could fail to restart the workload.
|
||||
# What IS covered is the op:// PATH of the two vault items, through
|
||||
# checksum/secret-ref on the pod template. A change INSIDE an item still
|
||||
# restarts nothing - Helm never sees it - and NOTES.txt names the manual
|
||||
# rollout restart for that case.
|
||||
#
|
||||
# 2. THE CHART RUNS A BUILD STEP IN AN INIT CONTAINER, AND IT HAS TO.
|
||||
# Keycloak distinguishes BUILD options from runtime options. Three of the
|
||||
# options this chart needs are build options - verified against the 26.8.0
|
||||
# "All configuration" page, where each carries the tool icon:
|
||||
# db (default dev-file)
|
||||
# health-enabled (default false)
|
||||
# metrics-enabled (default false)
|
||||
# The published image is augmented with those DEFAULTS. Setting any of them
|
||||
# at runtime makes Keycloak re-augment itself at start, and the augmentation
|
||||
# rewrites four files INSIDE the install tree:
|
||||
# /opt/keycloak/lib/quarkus/{generated-bytecode.jar,
|
||||
# transformed-bytecode.jar,quarkus-application.dat,
|
||||
# build-system.properties}
|
||||
# (read out of the image layer itself, not inferred). Upstream says what
|
||||
# that start does, in the container guide: it "detects and applies the build
|
||||
# options first", which "significantly increases startup time and CREATES AN
|
||||
# IMAGE THAT IS MUTABLE". A mutable image is exactly what
|
||||
# readOnlyRootFilesystem: true forbids - so the stock image, probes on
|
||||
# /health and a read-only root filesystem are not a combination that can
|
||||
# start. And "then do not set db" is no way out: THE PROBES ALONE force the
|
||||
# augmentation, because health-enabled is a build option too.
|
||||
#
|
||||
# So an init container from the SAME image runs `kc.sh build` into an
|
||||
# emptyDir that the main container mounts at /opt/keycloak/lib/quarkus, and
|
||||
# the main container runs `start --optimized`. No second image to pin (this
|
||||
# repository publishes charts, not images), hardening intact, and the
|
||||
# augmentation happens once in the init container instead of inside the
|
||||
# serving container on every start.
|
||||
#
|
||||
# THE CONDITION THAT MAKES IT SAFE, and the line a later round will want to
|
||||
# "simplify": the two containers must never disagree about the build
|
||||
# options, because a disagreement makes the MAIN container re-augment - on
|
||||
# a path that is now read-only, i.e. a crash loop. This chart enforces that
|
||||
# structurally rather than by care: the build options exist in exactly one
|
||||
# place (the keycloak.buildArgs helper, which only the init container uses),
|
||||
# the main container passes `start --optimized` and NO build option at all,
|
||||
# and the render FAILS if a keycloak.env entry carries a build option's
|
||||
# variable name. Do not add a KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED
|
||||
# or KC_FEATURES to the main container "for clarity".
|
||||
#
|
||||
# 3. NO PVC, AND NO StatefulSet. Keycloak 26 keeps everything in the database,
|
||||
# user sessions included (persistent user sessions are the default since 26;
|
||||
# volatile sessions are deprecated as of 26.8). There is no state on disk to
|
||||
# keep and no stable identity to preserve, so this is a Deployment - the
|
||||
# repo's StatefulSet rule is about STATEFUL applications, and here it would
|
||||
# be decoration.
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# the Deployment with replicas: 0 and takes precedence over keycloak.replicas.
|
||||
# Secrets, Services and Ingresses stay in place; scaleDown: false brings the
|
||||
# workload back up.
|
||||
#
|
||||
# THIS IS ALSO HALF OF THE MINOR-UPGRADE PROCEDURE. Keycloak supports a rolling
|
||||
# update only inside a minor line, so a 26.8 -> 26.9 jump is: scaleDown: true,
|
||||
# back up configuration and database, change the version, scaleDown: false.
|
||||
# NOTES.txt has the sequence and the compatibility check that decides it.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item carrying the database credentials.
|
||||
# REQUIRED - the chart has no default database and Keycloak does not start
|
||||
# without one.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# username the database role -> KC_DB_USERNAME
|
||||
# password that role's password -> KC_DB_PASSWORD
|
||||
#
|
||||
# Host, port and database name are NOT in the vault: they are plain values
|
||||
# under keycloak.db below, because they are not secret and because putting
|
||||
# them in the item would mean editing a vault entry to move a database.
|
||||
#
|
||||
# THEY ARRIVE AS ENVIRONMENT VARIABLES, NOT AS FILES, and that is forced
|
||||
# rather than chosen. 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 "All configuration" page - every KC_*_FILE
|
||||
# variable there is a PATH option (certificate, key store, trust store, log
|
||||
# file, export file), i.e. "point at this file", never "read this option's
|
||||
# value from a file". The file form valkey argues for is simply unavailable
|
||||
# here, as it was for garage, for a different reason.
|
||||
#
|
||||
# The residual price, stated rather than hidden: the password is in the
|
||||
# container's process environment, so anyone who can exec into the pod or
|
||||
# read its /proc can see it. (Keycloak's own Vault SPI, KC_VAULT=file, is a
|
||||
# different and larger mechanism - a directory of secret files with its own
|
||||
# naming scheme - and deliberately not used here.)
|
||||
databaseSecretRef: null
|
||||
|
||||
# op:// path of the 1Password item carrying the TEMPORARY bootstrap
|
||||
# administrator. Required while keycloak.bootstrapAdmin.enabled is true
|
||||
# (the default), which is what a first install needs.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# username -> KC_BOOTSTRAP_ADMIN_USERNAME (upstream default: temp-admin)
|
||||
# password -> KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
#
|
||||
# TWO THINGS ABOUT THIS ACCOUNT THAT CATCH PEOPLE OUT, both in NOTES.txt as
|
||||
# well because they are first-install work:
|
||||
# - the variables are named this way only since Keycloak 26. Before that
|
||||
# they were KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD, and a values file
|
||||
# copied from an older deployment silently creates no administrator at
|
||||
# all.
|
||||
# - the account is TEMPORARY. Upstream: "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." Creating a
|
||||
# permanent administrator is therefore a MANDATORY step after the first
|
||||
# install, not a recommendation.
|
||||
bootstrapAdminSecretRef: null
|
||||
|
||||
keycloak:
|
||||
# TWO, and the reason is the kind of service this is. A mail server may be
|
||||
# away briefly because SMTP retries; a blob store has one consumer. An SSO
|
||||
# server takes EVERY dependent login down with it, so the second replica is
|
||||
# the point of this chart rather than a tuning option.
|
||||
#
|
||||
# What makes two replicas cheap here: node discovery runs over the database
|
||||
# that is there anyway. cache=ispn plus Keycloak's default jdbc-ping stack
|
||||
# means no headless Service, no JGroups DNS wiring and no extra option - the
|
||||
# nodes register themselves in a table and then talk directly to each other
|
||||
# on port 7800. (cache-stack=kubernetes, the DNS_PING variant, is marked
|
||||
# deprecated upstream.)
|
||||
#
|
||||
# What it costs: twice the database connections, which is why the pool sizes
|
||||
# below are not upstream's defaults, and the rolling-update restriction -
|
||||
# with one replica a minor jump is a restart, with two it is a procedure.
|
||||
#
|
||||
# An explicit 0 is allowed and is the per-workload way to stop the release;
|
||||
# the chart-global scaleDown flag does the same for everything at once.
|
||||
replicas: 2
|
||||
|
||||
db:
|
||||
# THE DATABASE VENDOR, AND THE ONE VALUE THAT IS A BUILD OPTION (point 2
|
||||
# at the top of this file). It is passed to `kc.sh build` in the init
|
||||
# container and never to the serving container. Changing it re-runs the
|
||||
# build on the next pod start, which is correct and costs one init-container
|
||||
# run - it does NOT migrate any data.
|
||||
#
|
||||
# "postgres" is the only value this chart is tested against. The others
|
||||
# upstream accepts (mariadb, mysql, mssql, oracle) would render, but
|
||||
# nothing here or in CI has ever started them. dev-file and dev-mem are
|
||||
# rejected by the schema: they are Keycloak's development defaults, and an
|
||||
# identity provider on an embedded H2 file is the kind of install that
|
||||
# works until the pod moves.
|
||||
vendor: postgres
|
||||
|
||||
# Host of the external PostgreSQL. REQUIRED - there is no sensible default
|
||||
# and a wrong one is a pod that waits on a connection it will never get.
|
||||
host: null
|
||||
port: 5432
|
||||
database: keycloak
|
||||
|
||||
# CONNECTION POOL, AND A DELIBERATE DEPARTURE FROM UPSTREAM'S DEFAULT.
|
||||
# db-pool-max-size ships as 100 upstream (verified in the 26.8.0 option
|
||||
# table). With two replicas that is 200 connections against a PostgreSQL
|
||||
# whose own default max_connections is 100 - so the upstream default does
|
||||
# not merely waste connections, it hands out more than a default server
|
||||
# has, and the failure arrives under load as "too many clients already"
|
||||
# rather than at install time.
|
||||
#
|
||||
# 20 per replica = 40 in total leaves room for everything else on that
|
||||
# server. Raise it together with the server's max_connections, never alone,
|
||||
# and remember to multiply by keycloak.replicas when you do.
|
||||
#
|
||||
# initialSize and minSize have no upstream default; keeping a few warm
|
||||
# connections removes the first-request latency after an idle period.
|
||||
pool:
|
||||
initialSize: 2
|
||||
minSize: 2
|
||||
maxSize: 20
|
||||
|
||||
bootstrapAdmin:
|
||||
# Render KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD from the
|
||||
# vault item above. ON, because a fresh install has no way in otherwise:
|
||||
# Keycloak "only allows to create the initial admin user from a local
|
||||
# network connection", which a container never has.
|
||||
#
|
||||
# SET THIS TO false ONCE A PERMANENT ADMINISTRATOR EXISTS. While the
|
||||
# variables stay set, every start logs a line about the initial
|
||||
# administrator not being created, because the master realm already exists
|
||||
# and the bootstrap path is skipped. That line is harmless and it is NOT a
|
||||
# defect - but it is the kind of message somebody eventually investigates,
|
||||
# so switching this off after the first install removes both the noise and
|
||||
# a credential the pod no longer needs.
|
||||
enabled: true
|
||||
|
||||
# Expose /metrics on the management port (metrics-enabled, a BUILD option -
|
||||
# it goes to `kc.sh build`, so changing it re-runs the init container).
|
||||
#
|
||||
# OFF, and the reason is the port rather than the data: the management
|
||||
# interface is UNAUTHENTICATED in this chart's configuration, and switching
|
||||
# metrics on adds Keycloak's full metric set - realm names, client ids, login
|
||||
# and token counters - to a surface nothing authenticates. Switch it on
|
||||
# together with whatever scrapes it, and read
|
||||
# keycloak.ports.management below first.
|
||||
metrics:
|
||||
enabled: false
|
||||
|
||||
# Additional Keycloak feature flags (--features), e.g. ["organization",
|
||||
# "declarative-ui"]. A BUILD option: entries here change what the init
|
||||
# container builds, so adding one re-runs the build on the next pod start.
|
||||
#
|
||||
# Deliberately a list rather than a free-form string: the option is
|
||||
# comma-separated, and a string would let two entries arrive as one unknown
|
||||
# feature name, which Keycloak rejects at BUILD time - in the init container,
|
||||
# where the error is one `kubectl logs -c` away from being invisible.
|
||||
features: []
|
||||
|
||||
cache:
|
||||
# --cache-embedded-cluster-name. NULL, i.e. Keycloak's default "ISPN", and
|
||||
# the value exists because of a trap rather than a need.
|
||||
#
|
||||
# THE TRAP: node discovery runs through the database, so - upstream's own
|
||||
# words - "clusters that share the same database but should remain separate
|
||||
# will merge unless they have distinct cluster names". Two releases of this
|
||||
# chart pointed at the SAME database therefore form ONE Infinispan cluster,
|
||||
# silently, with no error on either side. If that is your situation, the
|
||||
# first thing to check is whether they should be sharing a database at all;
|
||||
# separate databases need no cluster name at all.
|
||||
#
|
||||
# WHY IT IS NOT SET PER RELEASE BY DEFAULT, although that is the obvious
|
||||
# fix: the same upstream page warns about the other direction - "As
|
||||
# invalidation of caches between clusters with distinct names will not
|
||||
# work, this will lead to stale caches and incorrect behavior. Use only
|
||||
# together with the stateless feature." That warning is not unambiguous
|
||||
# about which configuration it applies to, and a chart default that trades
|
||||
# a documented merge for a possibly undocumented invalidation gap is not an
|
||||
# improvement. So: the value is here, the trap is written down, and the
|
||||
# default is upstream's.
|
||||
clusterName: null
|
||||
|
||||
# THE THREE PORTS, IN ONE PLACE, because two templates have to agree on them:
|
||||
# the Deployment renders them as containerPorts and the Services address them
|
||||
# BY NAME. The probes also address the management port by name, so renaming
|
||||
# one here breaks them.
|
||||
ports:
|
||||
# The serving port. Behind the Ingress, plain HTTP - see http-enabled in
|
||||
# the Deployment.
|
||||
http: 8080
|
||||
|
||||
# THE MANAGEMENT PORT, AND THE ONE THING TO KNOW BEFORE TOUCHING ANY OF
|
||||
# THIS: it carries /health, /health/live, /health/ready, /health/started
|
||||
# and - with keycloak.metrics.enabled - /metrics, and it carries them
|
||||
# WITHOUT AUTHENTICATION. All three probes use it, which is why it exists
|
||||
# as a containerPort and as a ClusterIP Service.
|
||||
#
|
||||
# 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, and
|
||||
# /metrics tells them the realm and client names. Upstream's alternative
|
||||
# (legacy-observability-interface=true, which moves health back onto 8080
|
||||
# where the Ingress is) is explicitly "not recommended for security
|
||||
# reasons" and this chart does not offer it.
|
||||
management: 9000
|
||||
|
||||
# JGroups. Not served to anything and not in any Service - it is declared
|
||||
# so that the manifest says where cluster traffic goes, and so that a
|
||||
# NetworkPolicy (none yet in this chart) has a port to name. jdbc-ping
|
||||
# discovers peers through the database and then opens DIRECT pod-to-pod
|
||||
# connections on this port, so the two replicas need plain L3 reachability
|
||||
# between their pod IPs - not a Service, and not a headless one either.
|
||||
jgroups: 7800
|
||||
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a CPU
|
||||
# limit is only set when configured explicitly - CPU is compressible, so
|
||||
# leaving it unlimited throttles instead of OOM-killing.
|
||||
#
|
||||
# 700Mi x 3 = 2100Mi, AND THAT NUMBER IS LOAD-BEARING RATHER THAN TIDY.
|
||||
# The image sizes the JVM heap RELATIVE to the container limit
|
||||
# (-XX:MaxRAMPercentage=70, -XX:InitialRAMPercentage=50 in kc.sh), so the
|
||||
# limit is the value the heap is computed from, not a ceiling that is
|
||||
# hopefully never reached. Upstream's container guide: "you should always
|
||||
# set the memory limit for the container", "at least 750 MB", and "For
|
||||
# smaller production-ready deployments, the recommended memory limit is
|
||||
# 2 GB". 2100Mi is that recommendation; 70% of it is a ~1470Mi heap.
|
||||
#
|
||||
# Lowering the request without raising limitFactor shrinks the heap
|
||||
# proportionally. That is the connection to keep in mind here - in most
|
||||
# charts of this repo the request is about scheduling, in this one it also
|
||||
# decides how much memory the application believes it has.
|
||||
#
|
||||
# The ephemeral-storage request covers the three emptyDirs (the Quarkus
|
||||
# build output, /opt/keycloak/data and /tmp) plus container logs. The build
|
||||
# output is a few MB; the head room is for the augmentation's temporary
|
||||
# files.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 700Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
# The two INIT CONTAINERS - the seed step and the Quarkus build. Both use
|
||||
# this resources block, because the seed step is a single `cp` whose cost
|
||||
# disappears next to the build's and a second block would only be a second
|
||||
# thing to keep in step.
|
||||
build:
|
||||
# Resources for the build init containers. Their own block, because they do
|
||||
# something quite different from the serving container: a single-shot
|
||||
# Quarkus augmentation, CPU-bound, measured by upstream's own issue reports
|
||||
# at roughly 13-23 seconds of "Quarkus augmentation completed in ...".
|
||||
#
|
||||
# More CPU here shortens every pod start, and the memory is transient - the
|
||||
# init containers are gone before the serving container begins, so these
|
||||
# requests do not add to the pod's running footprint. They DO count for
|
||||
# scheduling, though: a pod's effective request is the maximum of the init
|
||||
# containers' and the sum of the containers', so raising cpu above the
|
||||
# serving container's request raises what the scheduler needs to find.
|
||||
resources:
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
# LIVENESS: /health/live on the management port - the endpoint that reports
|
||||
# whether this process is alive, and deliberately NOT /health or
|
||||
# /health/ready.
|
||||
#
|
||||
# This is guideline 12.3 in the one shape where upstream has already done the
|
||||
# work for us: Keycloak splits the three checks, and /health/live contains no
|
||||
# database check, while /health/ready and the aggregate /health do. A
|
||||
# liveness probe on either of those restarts every pod of the cluster for as
|
||||
# long as the database is away - which cannot fix a database, and which turns
|
||||
# a degraded login service into none at all. Same lesson as zipline (README,
|
||||
# guideline 12.3); here it is one path away.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# READINESS: /health/ready - which DOES include the database, and must. A
|
||||
# node that cannot reach its database serves errors to every login, so no
|
||||
# Service should route to it.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# STARTUP: /health/started, Keycloak's own "the server has finished booting"
|
||||
# endpoint. It gates liveness and readiness, which is why neither of those
|
||||
# carries an initialDelaySeconds.
|
||||
#
|
||||
# BUDGET = periodSeconds x failureThreshold = 10 x 60 = 600s, and it is
|
||||
# generous on purpose. What has to fit inside it is not only the JVM and
|
||||
# Infinispan: on a FIRST install Keycloak creates its entire schema through
|
||||
# Liquibase, and with two replicas starting at once the second waits on the
|
||||
# first's migration lock. A budget that is too short there produces a
|
||||
# CrashLoopBackOff that looks like an application fault and is really a
|
||||
# stopwatch.
|
||||
#
|
||||
# The Quarkus augmentation is NOT in this budget - it runs in the init
|
||||
# container, which has no probe and no deadline of its own. That is the
|
||||
# second thing the init container buys.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/started
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
timeoutSeconds: 5
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
# 1000 AND GROUP 0, which is not this repo's 20000/1000 default and not a
|
||||
# free choice: the published image declares "keycloak:x:1000:0" in
|
||||
# /etc/passwd, adds that user to the ROOT GROUP in /etc/group, and copies
|
||||
# the whole install tree with --chown=1000:0. Everything under
|
||||
# /opt/keycloak is therefore owned by 1000:0 and group-writable, and the
|
||||
# emptyDir mounted over /opt/keycloak/lib/quarkus has to be writable by
|
||||
# the same pair or the build writes nothing.
|
||||
#
|
||||
# Read off the image rather than guessed: `USER 1000` and the chown are
|
||||
# in the config blob's history of quay.io/keycloak/keycloak:26.8.0.
|
||||
runAsUser: 1000
|
||||
runAsGroup: 0
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: Always
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
# TRUE, and keeping it true is the whole reason this chart has an init
|
||||
# container (point 2 at the top). Setting it to false would make the
|
||||
# init container unnecessary - and would also mean an identity provider
|
||||
# that rewrites its own installation directory at every start. Do not
|
||||
# trade one for the other without reading that section.
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# EMPTY, and it can stay empty: 8080, 9000 and 7800 are all above
|
||||
# 1024, and the entrypoint is a shell script calling java - no file
|
||||
# capabilities, so no execve-time capability check like stalwart's.
|
||||
add: []
|
||||
|
||||
podDisruptionBudget:
|
||||
# A PodDisruptionBudget, the FIRST in this repository - and with two
|
||||
# replicas it is the piece that makes the second one worth having. Without
|
||||
# it a node drain is free to take both pods at once, and a drain is the
|
||||
# most common reason a pod moves: a node upgrade, not a crash.
|
||||
enabled: true
|
||||
|
||||
# minAvailable, not maxUnavailable, because the guarantee we want is about
|
||||
# the service and not about the disruption: at least one node answers
|
||||
# logins throughout a drain.
|
||||
#
|
||||
# THE TRAP THIS CHART AVOIDS BY NOT RENDERING THE OBJECT BELOW TWO
|
||||
# REPLICAS: minAvailable: 1 with exactly one replica forbids every
|
||||
# voluntary eviction, so `kubectl drain` hangs forever on a pod nothing can
|
||||
# move - and the message points at the PDB, not at the replica count. The
|
||||
# template therefore skips the PDB entirely while the effective replica
|
||||
# count is below 2 (keycloak.replicas: 1, or scaleDown: true), and
|
||||
# NOTES.txt prints whether it was rendered.
|
||||
minAvailable: 1
|
||||
|
||||
# Additive environment entries for the Keycloak container, rendered through
|
||||
# tpl and supporting the value, secretKeyRef and configMapKeyRef forms.
|
||||
#
|
||||
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the 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 to override KC_HOSTNAME or the database
|
||||
# variables; change the values that produce them.
|
||||
#
|
||||
# AND ONE CLASS OF ENTRY IS REFUSED AT RENDER TIME: a BUILD option's variable
|
||||
# name (KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED, KC_FEATURES and the
|
||||
# rest of the verified list in _helpers.tpl). 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,
|
||||
# i.e. a crash loop whose cause is three files away from the error message.
|
||||
# The render fails instead, naming the variable. Build options belong in the
|
||||
# values that feed the init container.
|
||||
#
|
||||
# Genuinely additive and useful here: JAVA_OPTS_APPEND, KC_LOG_LEVEL,
|
||||
# KC_SPI_* provider settings.
|
||||
env: []
|
||||
|
||||
ingress:
|
||||
# The PUBLIC ingress - the login endpoint, the token endpoints, the account
|
||||
# console. On by default, because an identity provider nothing can reach is
|
||||
# not an identity provider.
|
||||
enabled: true
|
||||
|
||||
# The public hostname. REQUIRED, AND REQUIRED EVEN WITH ingress.enabled:
|
||||
# false - this is guideline 12.1, checked in both directions: the value is
|
||||
# read a second time OUTSIDE the Ingress template, as KC_HOSTNAME on the
|
||||
# Deployment. Keycloak refuses to start in production mode without a
|
||||
# hostname (hostname-strict defaults to true), so gating it away would
|
||||
# produce a release that renders cleanly and never starts.
|
||||
#
|
||||
# IT IS RENDERED AS A FULL URL, https://<domain>, not as a bare host, and
|
||||
# that is upstream's requirement rather than a preference: "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". The chart
|
||||
# derives the URL, so this value stays a plain hostname.
|
||||
domain: null
|
||||
|
||||
# cert-manager cluster issuer. Required while either Ingress is enabled;
|
||||
# nothing outside the Ingress templates reads it, so switching both off frees
|
||||
# it.
|
||||
clusterIssuer: null
|
||||
className: nginx
|
||||
|
||||
# Extra annotations for the public Ingress; merged with the cert-manager
|
||||
# annotation the chart always sets.
|
||||
annotations: {}
|
||||
|
||||
admin:
|
||||
# THE ADMIN CONSOLE ON A HOSTNAME OF ITS OWN - and read the next paragraph
|
||||
# before you count on it for anything.
|
||||
#
|
||||
# THIS IS NOT AN ACCESS CONTROL. It sets Keycloak's hostname-admin option,
|
||||
# which decides WHICH URLs THE SERVER GENERATES - redirects, the CSS and JS
|
||||
# the console loads, and the API address the console itself calls. It does
|
||||
# not close anything. 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, with or
|
||||
# without this block, and an IP restriction on the admin host buys exactly
|
||||
# nothing on its own. Whoever wants the administration API restricted does
|
||||
# it on the reverse proxy in front of the PUBLIC host. This chart renders
|
||||
# no such rule: a path rule on /admin that nobody has tested against a
|
||||
# running console breaks redirects and asset loading, and a security
|
||||
# measure that only looks like one is worse than an absent one.
|
||||
#
|
||||
# What the second hostname IS good for: the console generates its own URLs
|
||||
# for it, so it can live on an internal ingress class or a different DNS
|
||||
# zone, and operators reach it by a name that is not the public login name.
|
||||
enabled: true
|
||||
|
||||
# The admin hostname. Required while ingress.admin.enabled is true. Also
|
||||
# rendered as a full URL (https://<domain>) for the same upstream reason as
|
||||
# ingress.domain.
|
||||
domain: null
|
||||
|
||||
# null falls back to ingress.className. Set it to an internal ingress class
|
||||
# to keep the console off the public controller - which is a useful thing
|
||||
# to do, and still not the access control the paragraph above is about.
|
||||
className: null
|
||||
|
||||
# Extra annotations for the admin Ingress only; merged with the cert-manager
|
||||
# annotation. This is where an IP allow-list would go
|
||||
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
|
||||
# above firmly in mind.
|
||||
annotations: {}
|
||||
Reference in new issue
Block a user