Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build keycloak (pull_request) Successful in 8s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 24s
Install-test / Install-test keycloak (pull_request) Successful in 3m29s
Validate Helm Charts / Install-test (pull_request) Successful in 3m29s
keycloak.realmHosts gives a realm a hostname of its own: this release's Service published under a further name, with that name's bare / sent to /realms/<realm>/account/. It renders in BOTH modes - one Ingress per entry carrying nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter - because a routing value in this repository is mode-independent by design - ingress.domain is read in Gateway mode too - so a feature rendering in one mode only would silently disappear when an installation switched. Default []: nothing renders, and the render is byte-identical to 1.2.0 for the fixture's values in both modes. Minor, additive, no value becomes required. ONE OBJECT PER ENTRY, not one with several hostnames. spec.hostnames applies to the whole object and a rule's matches are path-based, so inside a shared route every entry would match Exact: / and the realm could not be told apart. Names therefore carry the LIST INDEX, as homeassistant's per-host routes do and for the same reason - a realm name is not a dependable object-name fragment. The consequence is written down rather than left to be discovered: reordering the list renames and recreates the objects. THE WORK IS IN THE INGRESS HALF, NOT THE ROUTE. Two convention rules do not survive these objects, and both deviations are required rather than tolerated: tlsSecretName is a per-entry value with NO default, because these hostnames already have certificates and several entries typically share one, so a derived name would ask cert-manager for a second certificate for a name a wildcard already covers; and the cert-manager annotation is OPTIONAL per entry, because cert-manager keys a Certificate by the secret it writes and only one object per secret may request it - the same deliberate omission the qBittorrent object in freesailarr makes. Annotations are per entry too: of the four hostnames this was written for, three carry the proxy-buffer pair and one does not, which a chart-global block could not express. statusCode: 302 is written out at the field's own default. 301 and 302 are both Support: Core, so the portability trap of #119 - an implementation declining an Extended code sets Accepted: False on the WHOLE route - does not apply here; ingress-nginx emits 302 for app-root, and a value that is only accidentally right documents nothing. Five render aborts, each against a failure that reports nothing: an entry without a host (ingress-nginx reads an empty host as a catch-all for the whole controller), without a realm (/realms//account/, a 404 behind a healthy hostname and certificate), without a tlsSecretName in Ingress mode (the controller's default certificate, a browser-only failure), a repeated host or one the release already publishes (webhook rejection in Ingress mode, two merged routes decided by object age in Gateway mode), and a filled list with both modes off (no object at all, and nothing in the release that reveals the list was ignored). The entry-shape checks sit in a shared helper so they exist in both modes, and the Ingress template skips them while httpRoute.enabled is also true so that the specific message cannot hide the mutual-exclusion one. CI: the two entries live in ci/keycloak/test-values.yaml, which ci/httproute/keycloak.yaml is applied on top of, so one definition covers both modes - two Ingress objects in the install-test, two HTTPRoutes in the render test. They cover both Ingress forms, with and without a clusterIssuer, and the render test holds their inertness in Gateway mode. The httproute match test goes from 56 assertions over 21 charts to 60, keycloak's share from 4 to 8 plus two "not checked" lines for the backend-less redirect rules. The #118 prefix/redirect pairing correctly does not fire: it is scoped to rules that strip a prefix, and a realm route strips nothing. NOT IN THIS CHANGE: the migration. Nothing in jk-cluster-config is touched. With the default [] no running system changes, so the order a takeover has to run in is documented instead - remove the objects in the owning repository first, then install with the list filled, because the reverse or both at once is rejected by ingress-nginx's admission webhook and leaves a Fleet bundle in ErrApplied until forceSyncGeneration is raised. Whether anything there depends on those objects BY NAME is unchecked and named as unchecked. Fixes #122
645 lines
32 KiB
Smarty
645 lines
32 KiB
Smarty
{{/*
|
|
Render the body of a container "resources:" block from a values dict of the form
|
|
|
|
resources:
|
|
limitFactor: 3 # optional, default 3
|
|
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
|
limits: { ... } # optional, every entry optional
|
|
|
|
Rules (variant B, see issue #19):
|
|
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
|
to the Kubernetes key "ephemeral-storage").
|
|
- an explicitly set limit always wins.
|
|
- a missing memory / ephemeral-storage limit is computed as
|
|
limitFactor x the corresponding request.
|
|
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
|
unlimited; CPU is compressible, so this cannot kill the workload).
|
|
|
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
|
the chart's name as define prefix, because sharing it would mean a library
|
|
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
|
this chart takes none. The reference implementation is
|
|
template-chart/templates/_helpers.tpl.
|
|
*/}}
|
|
{{- define "keycloak.resources" -}}
|
|
{{- $resources := . | default dict -}}
|
|
{{- $factor := 3.0 -}}
|
|
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
|
{{- $factor = float64 $resources.limitFactor -}}
|
|
{{- end -}}
|
|
{{- $requests := dict -}}
|
|
{{- range $key, $value := ($resources.requests | default dict) -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $_ := set $requests (include "keycloak.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $limits := dict -}}
|
|
{{- range $key, $value := ($resources.limits | default dict) -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $_ := set $limits (include "keycloak.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- range $key, $request := $requests -}}
|
|
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
|
{{- $_ := set $limits $key (include "keycloak.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $out := dict -}}
|
|
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
|
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
|
{{- toYaml $out -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Normalize a resource name from values to its Kubernetes key.
|
|
*/}}
|
|
{{- define "keycloak.resourceKey" -}}
|
|
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
|
Input: dict "quantity" <string|number> "factor" <float64>.
|
|
*/}}
|
|
{{- define "keycloak.multiplyQuantity" -}}
|
|
{{- $quantity := include "keycloak.formatNumber" .quantity -}}
|
|
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
|
{{- if not $number -}}
|
|
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
|
{{- end -}}
|
|
{{- $suffix := trimPrefix $number $quantity -}}
|
|
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
|
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
|
{{- end -}}
|
|
{{- $product := mulf (float64 $number) .factor -}}
|
|
{{- if eq $product (floor $product) -}}
|
|
{{- printf "%d%s" (int64 $product) $suffix -}}
|
|
{{- else -}}
|
|
{{- printf "%s%s" (include "keycloak.formatNumber" $product) $suffix -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Render a number as a plain decimal string, never in exponential notation
|
|
(issue #157): the null-safe fallback helpers pass values through fromYaml,
|
|
which parses every number as float64, and Go's "%v" switches float64 to
|
|
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
|
|
Kubernetes quantity. Strings ("700Mi") are passed through untouched.
|
|
*/}}
|
|
{{- define "keycloak.formatNumber" -}}
|
|
{{- if kindIs "float64" . -}}
|
|
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
|
{{- else -}}
|
|
{{- printf "%v" . -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
|
null / empty override falls back to the defaults instead of erasing them
|
|
(issues #61, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
|
<string>. Output: YAML of the merged dict (consume it with fromYaml).
|
|
|
|
An override key written without children is YAML null, and Helm treats that
|
|
null as a deletion of the chart's default. This helper defines the opposite
|
|
semantics - an empty block means "chart defaults apply" - while an explicitly
|
|
set value still wins, including false and 0.
|
|
*/}}
|
|
{{- define "keycloak.defaultedDict" -}}
|
|
{{- $defaults := .defaults | default dict -}}
|
|
{{- $given := .given -}}
|
|
{{- $path := .path | default "value" -}}
|
|
{{- if kindIs "invalid" $given -}}
|
|
{{- $given = dict -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $given) -}}
|
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
|
{{- end -}}
|
|
{{- $out := deepCopy $defaults -}}
|
|
{{- range $key, $value := $given -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $default := index $defaults $key -}}
|
|
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
|
{{- $_ := set $out $key (fromYaml (include "keycloak.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
|
{{- else if kindIs "map" $default -}}
|
|
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
|
{{- else -}}
|
|
{{- $_ := set $out $key $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- toYaml $out -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Pick one sub-block out of a values block that may itself be null.
|
|
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
|
Returns YAML of dict "value" <the sub-block, may be nil>.
|
|
*/}}
|
|
{{- define "keycloak.subBlock" -}}
|
|
{{- $block := .block -}}
|
|
{{- if kindIs "invalid" $block -}}
|
|
{{- $block = dict -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $block) -}}
|
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
|
{{- end -}}
|
|
{{- toYaml (dict "value" (index $block .key)) -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
THE EFFECTIVE KEYCLOAK SETTINGS - everything the Deployment, the Services, the
|
|
PodDisruptionBudget and NOTES.txt read out of .Values.keycloak, merged onto this
|
|
chart's defaults.
|
|
|
|
Why the whole settings block goes through the null-safe helper and not only the
|
|
three usual suspects (guideline 12.2: "the rule is about the shape, not about
|
|
those three block kinds"): every value below is dereferenced by a template, and
|
|
an erased one would not abort the render - it would render a containerPort with
|
|
no number, a `--db=` with an empty right-hand side, or a database host that is
|
|
the empty string. The first is rejected by the API server at apply time, the
|
|
second fails the BUILD inside the init container, and the third is a pod waiting
|
|
on a connection that cannot be made. All three arrive after the render looked
|
|
fine, which is exactly the class #99 is about. satisfactory shipped the same
|
|
shape with half its blocks null-safe and half not (Gitea #16), and the half that
|
|
was not took the Service's port with it.
|
|
|
|
The defaults here MUST stay in sync with values.yaml, which remains the
|
|
documented, user-facing place for them; this copy is only the fallback.
|
|
|
|
Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
|
|
*/}}
|
|
{{- define "keycloak.settings" -}}
|
|
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" .Values "key" "keycloak" "path" ".Values"))).value -}}
|
|
{{- $defaults := dict
|
|
"replicas" 2
|
|
"db" (dict
|
|
"vendor" "postgres"
|
|
"host" nil
|
|
"port" 5432
|
|
"database" "keycloak"
|
|
"pool" (dict "initialSize" 2 "minSize" 2 "maxSize" 20))
|
|
"bootstrapAdmin" (dict "enabled" true)
|
|
"metrics" (dict "enabled" true)
|
|
"features" (list)
|
|
"cache" (dict "clusterName" nil)
|
|
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
|
|
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1)
|
|
"realmHosts" (list) -}}
|
|
{{- 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 REALM HOSTNAMES (Gitea #122) - validated here once, rendered twice.
|
|
|
|
WHAT THEY ARE. Keycloak serves every realm from one deployment, and a realm's
|
|
account console lives at /realms/<realm>/account/. A realm therefore gets a
|
|
hostname of its own by publishing THIS release's Service under that name and
|
|
sending the bare / to that path. The list is keycloak.realmHosts, it defaults
|
|
to empty, and an empty list renders nothing in either mode - the feature is
|
|
additive and the default render is byte-identical without it.
|
|
|
|
WHY THE VALIDATION IS SHARED AND NOT PER TEMPLATE. The same list feeds the
|
|
Ingress and the HTTPRoute template, so a check written in one of them is a check
|
|
that does not exist in the other mode - and the failure these checks are about
|
|
is a hostname that resolves, answers, and serves the wrong thing or nothing at
|
|
all. None of it is loud: an entry without a realm would render an Ingress whose
|
|
app-root annotation reads "/realms//account/", an entry without a host would
|
|
render an object with an empty host that ingress-nginx treats as a CATCH-ALL for
|
|
the whole controller, and an entry without a tlsSecretName would render a TLS
|
|
block with no secret, which serves the controller's default certificate and
|
|
fails in the browser only.
|
|
|
|
THE MODE DECIDES WHICH FIELDS ARE LOAD-BEARING, which is why it is a parameter
|
|
rather than read from the values here: tlsSecretName and annotations are Ingress
|
|
fields with no counterpart in the Gateway API (see keycloak.httproute.yaml), and
|
|
parentRefs is the other way round. A field demanded in a mode that ignores it is
|
|
the defect guideline 12 exists to remove, so each is required only where it is
|
|
read.
|
|
|
|
Input: dict "root" <the root context> "mode" "ingress"|"gateway"
|
|
Output: YAML of dict "entries" <the entries, as given, after validation>
|
|
*/}}
|
|
{{- define "keycloak.realmHosts" -}}
|
|
{{- $root := .root -}}
|
|
{{- $mode := .mode -}}
|
|
{{- $entries := (fromYaml (include "keycloak.settings" $root)).realmHosts | default list -}}
|
|
{{- if not (kindIs "slice" $entries) -}}
|
|
{{- fail (printf "keycloak.realmHosts must be a list or null, got %s (%v). One entry per realm hostname, each a mapping of host and realm plus the fields its routing mode reads - see values.yaml." (kindOf $entries) $entries) -}}
|
|
{{- end -}}
|
|
{{- $ingress := $root.Values.ingress | default dict -}}
|
|
{{- $ingressAdmin := $ingress.admin | default dict -}}
|
|
{{- $httpRoute := $root.Values.httpRoute | default dict -}}
|
|
{{- /*
|
|
THE HOSTNAMES THIS RELEASE ALREADY CLAIMS, collected so that a realm entry
|
|
cannot claim one of them a second time. Both modes punish it, differently and
|
|
neither of them clearly: under ingress-nginx the admission webhook rejects the
|
|
SECOND Ingress on the same host and path - which under Fleet leaves the bundle
|
|
in ErrApplied until forceSyncGeneration is raised - while two HTTPRoutes on one
|
|
listener and one hostname are MERGED by the implementation, so the duplicate is
|
|
accepted and which of the two identical rules serves a request is decided by
|
|
object age. The first is a deployment that stops, the second is a coin toss that
|
|
looks healthy.
|
|
*/ -}}
|
|
{{- $claimed := dict -}}
|
|
{{- if $ingress.domain -}}
|
|
{{- $_ := set $claimed (toString $ingress.domain) "ingress.domain, this release's public hostname" -}}
|
|
{{- end -}}
|
|
{{- if and (eq (include "keycloak.adminHostnameEnabled" $root) "true") $ingressAdmin.domain -}}
|
|
{{- $_ := set $claimed (toString $ingressAdmin.domain) "ingress.admin.domain, the administration console's hostname" -}}
|
|
{{- end -}}
|
|
{{- if eq $mode "gateway" -}}
|
|
{{- range ($httpRoute.hostnames | default list) -}}
|
|
{{- $_ := set $claimed (toString .) "httpRoute.hostnames, the public route's explicit hostname list" -}}
|
|
{{- end -}}
|
|
{{- range (($httpRoute.admin | default dict).hostnames | default list) -}}
|
|
{{- $_ := set $claimed (toString .) "httpRoute.admin.hostnames, the admin route's explicit hostname list" -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $seen := dict -}}
|
|
{{- range $i, $entry := $entries -}}
|
|
{{- $at := printf "keycloak.realmHosts[%d]" $i -}}
|
|
{{- if not (kindIs "map" $entry) -}}
|
|
{{- fail (printf "%s must be a mapping with at least host and realm, got %s (%v). The list is a list of entries, not of hostnames - the realm cannot be derived from the hostname, and this installation is the proof: id.klein.pub serves the realm klein-family." $at (kindOf $entry) $entry) -}}
|
|
{{- end -}}
|
|
{{- $host := toString ($entry.host | default "") -}}
|
|
{{- $realm := toString ($entry.realm | default "") -}}
|
|
{{- if not $host -}}
|
|
{{- fail (printf "%s has no host (its realm is %q). An Ingress rule or an HTTPRoute without a hostname is not an error in either API: ingress-nginx treats the empty host as a CATCH-ALL and the object then answers every name the controller serves that nothing else claims, while an HTTPRoute with an empty hostnames list attaches to every hostname of its listener. Both publish this realm's account console across hostnames nobody chose. Set %s.host." $at $realm $at) -}}
|
|
{{- end -}}
|
|
{{- if not $realm -}}
|
|
{{- fail (printf "%s (host %s) has no realm. The realm is the whole point of the entry - it is what the bare / redirects to - and without it the redirect target would read /realms//account/, which Keycloak answers 404 while the hostname, the certificate and the route are all healthy. Set %s.realm, and read it off the realm in Keycloak rather than off the hostname: the two need not match, and in this installation they do not." $at $host $at) -}}
|
|
{{- end -}}
|
|
{{- if contains "/" $realm -}}
|
|
{{- fail (printf "%s has realm %q, which contains a slash. The value is interpolated into the URL path /realms/<realm>/account/, so a slash does not escape it - it silently retargets the redirect at a different path. Use the realm's name as Keycloak shows it." $at $realm) -}}
|
|
{{- end -}}
|
|
{{- if hasKey $seen $host -}}
|
|
{{- fail (printf "%s repeats the host %s, already used by keycloak.realmHosts[%v]. Two entries on one hostname render two objects with the same hostname and the same path: in Ingress mode the admission webhook rejects the second, in Gateway mode both are accepted and merged and the redirect a client gets - so the realm it lands in - depends on which object is older. Give each realm its own hostname, or drop the duplicate entry." $at $host (index $seen $host)) -}}
|
|
{{- end -}}
|
|
{{- $_ := set $seen $host $i -}}
|
|
{{- if hasKey $claimed $host -}}
|
|
{{- fail (printf "%s claims the host %s, which this release already publishes through %s. One hostname cannot carry both: a realm entry sends the bare / to /realms/%s/account/, and the hostname it collides with serves the login endpoints or the administration console at / instead. Pick a hostname of its own for the realm - that is what the feature is for - or publish the realm through the existing host without this entry." $at $host (index $claimed $host) $realm) -}}
|
|
{{- end -}}
|
|
{{- if and (eq $mode "ingress") (not (toString ($entry.tlsSecretName | default ""))) -}}
|
|
{{- fail (printf "%s (host %s) has no tlsSecretName, and in Ingress mode it is required. Unlike this chart's own two Ingresses the realm objects do NOT get the conventional tls-<release>-keycloak-ingress name: these hostnames live on certificates that already exist - a wildcard covering several of them, or one per name - and letting the chart invent a secret name per entry would ask cert-manager for a SECOND certificate for a name that is already covered, which costs an ACME order per host and buys nothing. So the name is operator data and has to be given. If the certificate is to be issued for this entry, name a secret of its own here and set %s.clusterIssuer as well." $at $host $at) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- toYaml (dict "entries" $entries) -}}
|
|
{{- 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" 1000
|
|
"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 includes 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.
|
|
|
|
AND THE PATH ALONE DOES NOT GUARANTEE THAT, which is the part worth reading
|
|
before anybody "cleans up" keycloak.metrics.enabled. Upstream's table of
|
|
available checks carries a "Requires Metrics" column, and the DATABASE check -
|
|
"Returns the status of the database connection pool" - is the row that says
|
|
YES; Cluster, Graceful shutdown and Keycloak Initialized say no. So with
|
|
metrics-enabled=false this endpoint still answers 200 while the database is
|
|
gone, and the pod keeps taking logins it cannot serve.
|
|
|
|
That is exactly the urlaubsverwaltung shape (#218): the probe path was right
|
|
and the aggregate behind it contained nothing external. Here the dependency
|
|
hides behind an option whose name says observability, which is why
|
|
keycloak.metrics.enabled defaults to TRUE and why its comment in values.yaml
|
|
argues about readiness rather than about scraping.
|
|
*/}}
|
|
{{- 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 -}}
|