Files
jklein 7a2eb29e9e
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
feat(keycloak): one hostname per realm, in both routing modes
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
2026-10-13 10:13:08 +00:00

257 lines
13 KiB
YAML

{{- /*
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.
BOTH OBJECTS HANG OFF ingress.enabled, AND THE ADMIN ONE DID NOT USED TO
(fixed with the Gateway-API support, Gitea #114). ingress.admin.enabled is NOT
an Ingress switch: it decides whether KC_HOSTNAME_ADMIN is set on the
Deployment, i.e. whether the admin hostname is in play at all - so the admin
Ingress used to render with ingress.enabled: false, which meant
"ingress.enabled: false" did not mean "no Ingress" and clusterIssuer was still
required for an object nobody asked for. Measured before the fix:
`helm template -f ci/keycloak/test-values.yaml --set ingress.enabled=false`
emitted kc-keycloak-admin-ingress and nothing else of the routing path. It also
made Gateway mode impossible: switching httpRoute on leaves ingress.enabled
false, and the admin Ingress would have gone on claiming the admin host next to
the admin HTTPRoute - one host, two data paths, the exact conflict the mutual
exclusion exists to prevent. The default render is unchanged (ingress.enabled
defaults to true); what changed is a combination that could not be coherent.
*/ -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- /*
THE REALM HOSTNAMES WITH NO MODE TO CARRY THEM (Gitea #122), checked here
because this is the template that owns ingress.enabled and because the check
has to run when NEITHER routing template emits anything. Both modes off is a
legitimate configuration - the chart then publishes nothing and NOTES.txt says
so - but a filled keycloak.realmHosts next to it is not: the list would be
accepted, validated, and rendered into no object at all, which is the one
outcome nobody can tell from a working install by looking at the release.
Emitting nothing is only correct when nothing was asked for.
*/ -}}
{{- if and (not .Values.ingress.enabled) (not (.Values.httpRoute | default dict).enabled) }}
{{- if (fromYaml (include "keycloak.settings" .)).realmHosts }}
{{- fail "keycloak.realmHosts is set while BOTH routing modes are off (ingress.enabled: false and httpRoute.enabled: false). The entries name hostnames and realms and there is no object to put them in: no Ingress, no HTTPRoute, no DNS answer, and nothing in the rendered release that reveals the list was ignored. Switch on the mode these hostnames are meant to be served by, or remove the entries - both are coherent, this combination is not." }}
{{- end }}
{{- end }}
{{- /*
Validated and bound OUTSIDE the ingress.enabled block, because a variable
declared inside an if-block does not exist after it and the realm objects below
need the same list. The helper is only CALLED in Ingress mode: it demands a
tlsSecretName per entry, which the Gateway mode neither reads nor should ask
for.
AND NOT WHEN httpRoute.enabled IS ALSO TRUE, which looks redundant next to
ingress.enabled and is not. That combination is the one keycloak.httproute.yaml
aborts over, with a message about the two data paths; template execution order
is not something a chart can rely on, so a realm check that fired here would
sometimes answer "this entry needs a tlsSecretName" to a values file whose
actual defect is that both modes are on. The specific message must not be able
to hide the structural one. Measured while writing this: without the second
condition, exactly that happened.
*/ -}}
{{- $realmHosts := list }}
{{- if and .Values.ingress.enabled (not (.Values.httpRoute | default dict).enabled) }}
{{- $realmHosts = (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "ingress"))).entries }}
{{- end }}
{{- 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 and .Values.ingress.enabled $adminEnabled }}
---
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 }}
{{- range $i, $entry := $realmHosts }}
---
{{/*
A REALM HOSTNAME (Gitea #122), one Ingress object per keycloak.realmHosts entry.
Keycloak serves every realm from this one deployment, and a realm's account
console lives at /realms/<realm>/account/. An entry here gives a realm a
hostname of its own: the same Service as the two objects above, under a further
name, with the bare / sent to that realm's console by app-root. Gateway mode's
counterpart is an Exact match plus a RequestRedirect filter - see
keycloak.httproute.yaml.
THIS IS WHERE THE CHART DEVIATES FROM THE REPO'S INGRESS CONVENTION, and the
deviation is the expensive half of the feature rather than an oversight. The
convention (style guide §4) is one TLS secret per object named
tls-<release>-<chart>-ingress, with the cert-manager annotation ALWAYS set.
Neither holds here:
- THE SECRET NAME IS OPERATOR DATA, required per entry and never derived.
These hostnames live in zones whose certificates already exist, and several
of them typically share one: a wildcard for a zone covers every realm host
under it. A chart-derived name per entry would ask cert-manager for a fresh
certificate for a name a wildcard already covers - one ACME order per
hostname, for nothing.
- THE ISSUER IS OPTIONAL, and omitting it is the normal case rather than the
exception. cert-manager keys a Certificate by the secret it writes, so two
objects naming one secret and both carrying the annotation request the same
certificate twice. Exactly one object per secret may carry it - which is the
same deliberate omission the qBittorrent object in freesailarr makes, for
the same reason. Where an entry's certificate is to be issued FOR it, it
names a secret of its own and sets clusterIssuer; where the certificate
comes from elsewhere (a wildcard, a Certificate in cluster-config, another
release), clusterIssuer stays empty and this object only consumes the
secret.
The entry's annotations are merged last and per entry, not chart-globally,
because this is not a uniform set: of the four hostnames this installation runs
today, three carry the proxy-buffer pair and one does not. A chart-global
annotation block could not express that difference.
*/}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
{{- /*
INDEX-BASED, like homeassistant's per-host routes and for the same reason
(style guide §4): the count is data-driven, and a realm name is not a
dependable object-name fragment - the 63-character label limit plus the
release prefix, and a sanitised name can collide with another entry's. The
price, written down rather than discovered: reordering keycloak.realmHosts
RENAMES these objects, so they are recreated rather than edited, and the
hostname is briefly unserved while that happens. The TLS secret is not the
chart's and survives it. NOTES.txt prints which index carries which hostname
and realm, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-ingress{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
annotations:
{{- /*
The counterpart of the Gateway mode's Exact-match RequestRedirect.
ingress-nginx renders this annotation as
if ($uri = /) { return 302 $scheme://$http_host<app-root>; }
(rootfs/etc/nginx/template/nginx.tmpl:1000, controller-v1.14.5) - a 302 on
exactly /, with every other path proxied unchanged.
*/}}
nginx.ingress.kubernetes.io/app-root: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- with $entry.clusterIssuer }}
cert-manager.io/cluster-issuer: {{ . | quote }}
{{- end }}
{{- with $entry.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- /*
The entry's own class, falling back to ingress.className - the same shape as
ingress.admin.className. A realm hostname can perfectly well belong to a zone
served by a different controller than the login host.
*/}}
ingressClassName: {{ $entry.className | default $.Values.ingress.className }}
rules:
- host: {{ $entry.host | quote }}
http:
paths:
{{- /*
The SAME backend as the two objects above, by port NAME as they do.
Everything but the bare / is proxied straight through; app-root
handles /.
*/}}
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ $entry.host | quote }}
secretName: {{ $entry.tlsSecretName | quote }}
{{- end }}