Files
helm-charts/keycloak/templates/keycloak.httproute.yaml
T
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

274 lines
14 KiB
YAML

{{/*
HTTPRoute - the Gateway-API alternative to keycloak.ingress.yaml (Gitea #114).
TWO ROUTES, because this chart has TWO Ingress objects: the public hostname and
the admin hostname. Read keycloak.ingress.yaml first - everything it says about
what the admin hostname is NOT (it is not an access control) holds here
unchanged, and the Gateway API gives no new way to make it one.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object. That subset is identical
on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is the whole
point (decision 1 of #114). Class and certificate therefore belong to the
PLATFORM, on the Gateway and its listeners, in cluster-config.
WHAT ingress.admin.className BECOMES, AND WHY THERE IS AN admin BLOCK HERE
The admin Ingress exists so the console can sit on an INTERNAL ingress class or
in a different DNS zone (ingress.admin.className, falling back to
ingress.className). "Which data path" is a property of the Gateway in this API,
not of the route, so its counterpart is the PARENT: httpRoute.admin.parentRefs,
falling back to httpRoute.parentRefs exactly as the class does. Without that
fallback the admin route could only ever hang off the same Gateway as the public
one, and the one useful half of the second hostname would be gone in this mode.
THE ANNOTATIONS THIS MODE DROPS ARE NOT HYPOTHETICAL FOR THIS CHART
Measured on the live cluster on 2026-10-12, on BOTH of this chart's Ingress
objects (jk-cluster/keycloak):
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
The CHART sets neither - they are operator-supplied, through
ingress.annotations and ingress.admin.annotations, which is why a reader of
this repository alone would never find them. They are also not cosmetic:
Keycloak's responses carry large headers (JWTs, SAML assertions, long Set-Cookie
chains on the login flow), and a proxy whose buffer is too small answers 502
ITSELF. The failure then looks like Keycloak being broken while Keycloak logged
a perfectly successful request.
ingress.annotations and ingress.admin.annotations are INERT in this mode - the
Gateway API has no annotation channel for controller behaviour, and buffer
sizing has no counterpart in the specification at all; it is implementation
policy. So the Gateway branch of NOTES.txt NAMES it instead of rebuilding it
(decision 2 of #114). Whoever migrates this host sets the equivalent next to
the Gateway, in that implementation's own object.
THE REALM ROUTES, AND WHY THERE IS ONE OBJECT PER ENTRY (Gitea #122)
keycloak.realmHosts publishes further hostnames onto the SAME Service, each
sending the bare / to one realm's account console. Empty by default, so this
block renders nothing unless an installation fills it.
ONE OBJECT PER ENTRY, never one object with several hostnames. spec.hostnames
applies to the WHOLE object and a rule's matches are path-based, so the realm
could not be told apart inside a shared route: all entries would match
`Exact: /` and whichever rule the implementation picked would send every
hostname to the same realm. The count is therefore data-driven, which is also
why the object names are INDEX-based (style guide §4, as homeassistant does it)
- a realm name is not a dependable object-name fragment and a sanitised one can
collide. The consequence, written down rather than discovered: REORDERING THE
LIST RENAMES ROUTES, so they are recreated rather than edited. A route holds no
state and no certificate, which is what makes that acceptable; the TLS secret
the Ingress mode references is not the chart's and survives untouched either
way.
TWO RULES PER OBJECT, AND THE REDIRECT IS AN EXACT MATCH. The Ingress
counterpart is nginx.ingress.kubernetes.io/app-root, which ingress-nginx renders
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 /, and everything else proxied unchanged. So: an Exact match on / with a
RequestRedirect to /realms/<realm>/account/, plus the ordinary PathPrefix rule
on the Service. The Exact rule wins on / by the specification's precedence
("Exact" path match ahead of prefix matches) INDEPENDENTLY of its position in
the list, so the order below is a rendering convention and not a promise anyone
has to rely on.
statusCode IS WRITTEN OUT, at the value the field already defaults to. 301 and
302 are both Support: Core here, so the portability trap of #119 - an
implementation declining an Extended status code sets Accepted: False on the
WHOLE route, taking its other hostnames down with it - does not apply. The
reason to write it anyway: ingress-nginx emits 302 and a value that is merely
accidentally right documents nothing.
WHAT THE REALM ENTRIES LOSE IN THIS MODE is the per-entry annotations block,
and for three of the four hostnames this installation runs today that means the
proxy buffers - see the paragraph above, which is about the same loss on this
chart's own two objects. It is worse here than there in one respect only: these
hostnames serve ACCOUNT CONSOLE sessions, so the large-header case is the
normal case rather than the edge, and the 502 arrives from the proxy on a
hostname whose Ingress never had a chart value to point at.
ingress.clusterIssuer and the entries' tlsSecretName are not read in this mode:
the certificates for these hostnames sit on the Gateway's listeners, where a
wildcard listener covers whole zones at once.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Keycloak it is worse than for a stateless app, because the server derives KC_HOSTNAME from ingress.domain and generates absolute redirect and asset URLs for it - a login that starts on one path and finishes on the other fails on the state cookie, not with an error that names the cause. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $admin := $httpRoute.admin | default dict -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "gateway"))).entries -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
required in EVERY mode in this chart and its required() lives in the
keycloak.frontendUrl helper, because the Deployment reads it as KC_HOSTNAME -
guideline 12.1, and the reason this chart's value cannot be gated away. The
required() below is therefore a second, redundant guard rather than the only
one; it stays so the message names the route when the route is what is missing
a hostname.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the public HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the serving
Service. The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. It is read from the same settings helper the
Service renders its port from, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- if $adminEnabled }}
---
{{- /*
The admin route. Gated on ingress.admin.enabled, the same switch the admin
Ingress uses, because that value is not an Ingress switch: it also decides
whether KC_HOSTNAME_ADMIN is set on the Deployment, i.e. whether the admin
hostname is in play AT ALL. So it stays mode-independent and the ROUTING
OBJECT follows the mode - which is also why switching to Gateway mode must not
require setting it to false.
*/ -}}
{{- $adminHostnames := $admin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (required "An admin Domain/Host must be provided while ingress.admin.enabled is true: set ingress.admin.domain (used as the admin HTTPRoute's single hostname when httpRoute.admin.hostnames is empty) or set httpRoute.admin.hostnames explicitly" ((.Values.ingress.admin | default dict).domain)) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the public one - the counterpart of
ingress.admin.className falling back to ingress.className. An internal
Gateway here is the useful half of the admin hostname, and still not the
access control keycloak.ingress.yaml is about.
*/}}
parentRefs:
{{- toYaml ($admin.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
{{- toYaml $adminHostnames | nindent 4 }}
rules:
{{- /*
The SAME backend and the same path as the public route. 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.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
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 is that reordering keycloak.realmHosts RENAMES these routes, so they are
recreated rather than edited. Which hostname and which realm each index
carries is printed by NOTES.txt, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the chart-global one - the same shape as
httpRoute.admin.parentRefs. It is not decoration here: these hostnames sit in
several DNS zones, so a Gateway that terminates them needs either a wildcard
listener per zone or a named listener per host, and a chart-global parentRef
pinning one sectionName would attach every realm route to one certificate's
listener. A route whose listener does not match its hostname is not an error
either - it is simply not accepted, and the hostname does not answer.
*/}}
parentRefs:
{{- toYaml ($entry.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
- {{ $entry.host | quote }}
rules:
{{- /*
RULE ONE, the counterpart of nginx.ingress.kubernetes.io/app-root. Exact
on / so that only the bare entry point is redirected and every other path
- the realm's own endpoints, its assets, the token endpoints - continues
to be proxied by rule two. Exact outranks every prefix match in the
specification's precedence, so this rule wins on / wherever it is emitted.
*/}}
- matches:
- path:
type: Exact
value: /
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- /*
Written out at the field's own default. 301 and 302 are both
Support: Core, so the trap of #119 - an implementation declining an
Extended code sets Accepted: False on the WHOLE route - is not in
play; ingress-nginx emits 302 for app-root, and a value that is only
accidentally right documents nothing.
*/}}
statusCode: 302
{{- /*
RULE TWO: the same Service, the same port and the same prefix as the
public route. These hostnames are not a second application - they are the
same Keycloak under a name that lands in one realm.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- end }}