{{/* 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; }` (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//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 }}