feat(keycloak): Gateway-API as an alternative to the two Ingresses

Group 3 of the #114 sweep, first chart. keycloak has TWO Ingress objects -
the public hostname and the admin hostname - so it gets TWO HTTPRoutes, with
httpRoute.admin.parentRefs falling back to httpRoute.parentRefs exactly as
ingress.admin.className falls back to ingress.className: "which data path" is
a property of the Gateway in this API, not of the route, so an internal
Gateway here is what an internal ingress class is in the other mode.

The admin route stays gated on ingress.admin.enabled, 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 exists at all. Switching to
Gateway mode therefore must not require turning it off.

FIXED ALONG THE WAY: the admin Ingress did not hang off ingress.enabled.
Measured on origin/main, `helm template -f ci/keycloak/test-values.yaml --set
ingress.enabled=false` emitted kc-keycloak-admin-ingress - so
"ingress.enabled: false" did not mean "no Ingress", and ingress.clusterIssuer
stayed required for an object nobody asked for. It also made Gateway mode
impossible: the admin Ingress would have gone on claiming the admin host next
to the admin HTTPRoute, one host over two data paths, which is the exact
conflict the mutual exclusion exists to prevent. The default render is
unchanged; what changed is a combination that could not be coherent.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE CLUSTER rather than inferred. On
2026-10-12 BOTH of this chart's Ingress objects in jk-cluster carried

    nginx.ingress.kubernetes.io/proxy-buffer-size:    16k
    nginx.ingress.kubernetes.io/proxy-buffers-number: 4

set by the OPERATOR through ingress.annotations and ingress.admin.annotations
- the chart sets neither, which is why reading the chart alone does not reveal
them. The Gateway API has no annotation channel and no counterpart for proxy
buffer sizing; it is implementation policy, not routing. Per decision 1 of
#114 the chart renders no policy object, so this is NAMED and not rebuilt, in
values.yaml, in the schema and in the Gateway branch of NOTES.txt, with the
consequence spelled out in Keycloak's own terms: large headers (JWTs, SAML
assertions, long Set-Cookie chains on the login flow) make a too-small buffer
answer 502 at the PROXY while Keycloak logs the request as successful.

Named and deliberately not touched: four further Ingress objects in the same
namespace (keycloak-realm-*-ingress, with app-root) belong to the Helm release
jk-cluster-config-fleet-cd-keycloak-realm-ingress, i.e. to another repository.
They point at this release's Service under four further hostnames. This chart
renders exactly the two routes its own two Ingresses cover.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/keycloak/test-values.yaml.

Part of #114
This commit is contained in:
jklein authored and jklein committed 2026-10-13 08:59:09 +00:00
1 parent d36112aaf6
commit d0f0d49a93
7 files changed
+470 -13

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/keycloak/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture is the
# expensive and informative one - two replicas against a throwaway PostgreSQL,
# two init containers, `kc.sh build` under readOnlyRootFilesystem, and the
# Infinispan cluster forming over jdbc-ping (see that file for the four things
# only a real install can prove). Trading all of it for a CRD schema check that
# cyberchef's fixture already performs against the real Gateway-API CRDs would
# be a bad swap. The install keeps the two Ingresses; the two routes are held
# here, at render level, which is where their silent failure modes live.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, so an
# overlay that only switched httpRoute on would turn this test red.
#
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS:
#
# - ingress.domain comes from the fixture (keycloak.ci.example.com), so the
# PUBLIC route renders with its hostname from that single source of truth
# (httpRoute.hostnames stays empty on purpose - the fallback is the path
# that every installation will actually use).
# - ingress.admin.enabled is true in the fixture with
# ingress.admin.domain set, so the ADMIN route renders too, and its
# parentRefs fall back to httpRoute.parentRefs below - the empty-admin
# branch, which is the one an installation hits unless it wants an
# internal Gateway.
# - both routes' backendRef is checked against the serving Service of the
# same render. Both point at the SAME Service, by PORT NUMBER
# (keycloak.ports.http) rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber - so this overlay is what holds
# that number against the Service's.
#
# NOT covered here, and it cannot be: that either hostname answers. No Gateway
# controller runs in CI, no route reaches "Accepted: True", and the
# proxy-buffer-size/proxy-buffers-number annotations this chart's live
# installation relies on have no counterpart in this mode at all - that loss is
# documented in values.yaml and printed by NOTES.txt, and documentation is not
# something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# procedure lives in NOTES.txt and on the wiki page instead of in a # 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 # packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
# merging an app-version PR that changes the minor. # merging an app-version PR that changes the minor.
version: 1.1.0+up26.8.0 version: 1.2.0+up26.8.0
appVersion: "26.8.0" appVersion: "26.8.0"
maintainers: maintainers:
+111 -8
View File
@@ -1,6 +1,23 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}} {{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}} {{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}} {{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $httpRouteAdmin := $httpRoute.admin | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $adminHostnames := $httpRouteAdmin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (.Values.ingress.admin | default dict).domain -}}
{{- end -}}
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}} {{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .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 }}. Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
@@ -8,19 +25,97 @@ Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in nam
{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database, {{ $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 }}. external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}.
ENDPOINTS ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }} {{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }}
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only {{ else if $httpRoute.enabled }}{{ range $hostnames }} Public: https://{{ . }}
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin {{ end }}{{ else }} Public: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is both false) - in-cluster only
{{ end }}{{ if not $adminHostname }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
served on the public host and Keycloak generates its URLs for it served on the public host and Keycloak generates its URLs for it
{{ else if $httpRoute.enabled }}{{ range $adminHostnames }} Admin: https://{{ . }}/admin
{{ end }}{{ else if .Values.ingress.enabled }} Admin: {{ include "keycloak.adminUrl" . }}/admin
{{ else }} Admin: {{ include "keycloak.adminUrl" . }}/admin is what KC_HOSTNAME_ADMIN says, and
NOTHING PUBLISHES IT. ingress.admin.enabled is true, so the console
still generates its URLs for that host, but with both modes off
there is no Ingress and no HTTPRoute to carry it. Use the
port-forward below, or switch a mode on.
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }} {{ 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 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 }} /health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
WITHOUT authentication. It has a ClusterIP Service WITHOUT authentication. It has a ClusterIP Service
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only. ({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders {{ if $adminHostname }}TWO HTTPRoutes{{ else }}ONE HTTPRoute{{ end }} and NOTHING else of the routing path -
no Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
{{- if $adminHostname }}
{{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute {{ join ", " $adminHostnames }}
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway(s) named in parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (public)
{{- end }}
{{- if $adminHostname }}
{{- range ($httpRouteAdmin.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (admin)
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificates for those hostnames, on the Gateway's listeners.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
for this release.
AND THE ONE YOU WILL NOT NOTICE UNTIL A LOGIN FAILS: THE PROXY BUFFERS.
Keycloak's responses carry large headers - JWTs, SAML assertions, long
Set-Cookie chains during the login flow. In Ingress mode that is handled by
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
which this chart has never set: they come from ingress.annotations and
ingress.admin.annotations, i.e. from whoever installs it. IN THIS MODE THEY
ARE GONE and there is NO counterpart in the Gateway API - buffer sizing is
implementation policy, not routing, and this chart renders no policy object
on purpose (it would bind the chart to one Gateway implementation).
The failure mode, so you recognise it: the PROXY answers 502 while Keycloak
logs the request as successful. Nothing says a chart value was ignored,
because no chart value was involved. Set the equivalent next to the Gateway,
in your implementation's own object, BEFORE you point DNS at it - and raise
it, do not just match 16k, if you use long-lived tokens or SAML.
ingress.annotations and ingress.admin.annotations are inert here in general:
the Gateway API has no annotation channel for controller behaviour. An IP
allow-list for the admin host is the other thing that has to move - and read
the first trap below before relying on it in either mode.
THE ADMIN HOSTNAME'S "INTERNAL CLASS" MOVES TO THE PARENT. What
ingress.admin.className does in the other mode, httpRoute.admin.parentRefs
does here: point the admin route at an internal Gateway. It falls back to
httpRoute.parentRefs, so leaving it empty puts both hostnames on the same
Gateway.
{{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD, so a missing or The operator materializes one Secret key per item FIELD, so a missing or
misnamed field leaves the pod in CreateContainerConfigError - with no message misnamed field leaves the pod in CreateContainerConfigError - with no message
@@ -61,7 +156,7 @@ FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE
jdbc-ping did not find the peer - check that both pods can reach each jdbc-ping did not find the peer - check that both pods can reach each
other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database. other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database.
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 }} 3. Log in at {{ if and $adminHostname (not $httpRoute.enabled) }}{{ include "keycloak.adminUrl" . }}/admin{{ else if $adminHostname }}https://{{ first $adminHostnames }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else if $httpRoute.enabled }}https://{{ first $hostnames }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
with the bootstrap credentials from the vault item above. with the bootstrap credentials from the vault item above.
4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one. 4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one.
@@ -132,7 +227,10 @@ TRAPS WORTH KNOWING BEFORE YOU NEED THEM
on the reverse proxy level." So /admin/realms/... is reachable through the 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 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 admin Ingress restricts nothing on its own. This chart renders no deny rule
for /admin - see values.yaml for why not. for /admin - see values.yaml for why not.{{ if $httpRoute.enabled }} In GATEWAY MODE there is not
even a place in this chart to put one: no annotation channel, and no
implementation-specific policy object by design. The restriction belongs
next to the Gateway, which is where it can also be tested.{{ end }}
- A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the - A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the
database, so - upstream - "clusters that share the same database but should database, so - upstream - "clusters that share the same database but should
@@ -200,5 +298,10 @@ WHAT THIS CHART DOES NOT RENDER
- No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port - 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 {{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
replicas, or the cluster silently becomes two islands. replicas, or the cluster silently becomes two islands.
- No deny rule for /admin on the public Ingress, and no restriction of the - No deny rule for /admin on the public Ingress{{ if $httpRoute.enabled }} or route{{ end }}, and no restriction of
administration REST API. That is reverse-proxy work - see the first trap. the administration REST API. That is reverse-proxy work - see the first
trap.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
+164
View File
@@ -0,0 +1,164 @@
{{/*
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.
A NEIGHBOUR ON THE SAME ZONE, WHICH THIS CHART DOES NOT OWN
The same namespace carries four more Ingress objects - keycloak-realm-*-ingress
with nginx.ingress.kubernetes.io/app-root - from the Helm release
jk-cluster-config-fleet-cd-keycloak-realm-ingress, i.e. from ANOTHER
repository. They point at THIS release's Service under four further hostnames
(bk.id..., id.mail..., muth-event.id..., id.klein.pub). They are not rendered
here and must not be: this chart renders exactly the two routes its own two
Ingresses cover. Migrating the host is therefore not finished by switching this
chart - see the wiki page for the list.
*/}}
{{- /*
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 -}}
{{- /*
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 }}
{{- end }}
+16 -3
View File
@@ -29,6 +29,21 @@ 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 required even with ingress.enabled: false, and its required() lives in the
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
this file, so switching both Ingresses off frees it. 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" }} {{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- if .Values.ingress.enabled }} {{- if .Values.ingress.enabled }}
@@ -61,10 +76,8 @@ spec:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }} - {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }} {{- end }}
{{- if $adminEnabled }} {{- if and .Values.ingress.enabled $adminEnabled }}
{{- if .Values.ingress.enabled }}
--- ---
{{- end }}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
+41 -1
View File
@@ -187,15 +187,55 @@
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.", "description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open. Inert in Gateway mode.",
"type": "object" "type": "object"
} }
} }
} }
} }
},
"httpRoute": {
"description": "Gateway-API alternative to the TWO Ingresses (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, the public hostname and the admin one; the second is gated on ingress.admin.enabled, because that value also sets KC_HOSTNAME_ADMIN on the Deployment and is therefore not an Ingress switch. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. NOT COVERED IN THIS MODE: the proxy-buffer-size and proxy-buffers-number annotations this installation relies on - the Gateway API has no counterpart for proxy buffer sizing, and Keycloak's large headers make a too-small buffer answer 502 at the proxy while the application logs success. See values.yaml, httpRoute block.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the public route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the public hostname has a single source of truth.",
"type": "array",
"items": { "type": "string" }
},
"admin": {
"description": "The admin route. Rendered while ingress.admin.enabled is true - the same switch the admin Ingress uses, so the admin hostname is available in both modes.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"parentRefs": {
"description": "Empty falls back to httpRoute.parentRefs - the counterpart of ingress.admin.className falling back to ingress.className, because \"which data path\" is a property of the Gateway in this API and not of the route. An internal Gateway here is what an internal ingress class is in the other mode, and still not an access control.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.admin.domain].",
"type": "array",
"items": { "type": "string" }
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": { "port": {
"type": ["integer", "null"], "type": ["integer", "null"],
"minimum": 1, "minimum": 1,
+85
View File
@@ -642,6 +642,11 @@ ingress:
# Extra annotations for the public Ingress; merged with the cert-manager # Extra annotations for the public Ingress; merged with the cert-manager
# annotation the chart always sets. # annotation the chart always sets.
#
# INERT IN GATEWAY MODE (httpRoute.enabled) - the Gateway API has no
# annotation channel for controller behaviour. That matters for THIS chart in
# particular: see the httpRoute block below for the proxy-buffer annotations
# this installation actually relies on and what their loss looks like.
annotations: {} annotations: {}
admin: admin:
@@ -684,4 +689,84 @@ ingress:
# annotation. This is where an IP allow-list would go # annotation. This is where an IP allow-list would go
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat # (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
# above firmly in mind. # above firmly in mind.
#
# INERT IN GATEWAY MODE, like ingress.annotations - see the httpRoute block
# below for what this installation actually has in here today and what
# losing it costs.
annotations: {} annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE TWO INGRESSES (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY
# HTTPRoutes with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what makes the Gateway implementation replaceable without touching this
# chart again.
#
# TWO ROUTES, mirroring the two Ingresses: the public hostname and the admin
# hostname. ingress.admin.enabled keeps gating BOTH forms, because it is not an
# Ingress switch - it decides whether KC_HOSTNAME_ADMIN is set on the
# Deployment, i.e. whether the admin hostname exists at all.
#
# WHAT THIS MODE COSTS THIS CHART, MEASURED AND NOT HYPOTHETICAL. On
# 2026-10-12 both of this chart's live Ingress objects carried
#
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: 4
#
# set by the OPERATOR through the two annotations blocks above - this chart
# sets neither, which is why reading the chart alone does not reveal them. The
# Gateway API has no annotation channel and no counterpart for proxy buffer
# sizing: it is implementation policy, not routing, so there is nothing here to
# render and nothing this chart could promise. The consequence is specific:
# Keycloak's responses carry large headers (JWTs, SAML assertions, long
# Set-Cookie chains during the login flow), and a proxy whose buffer is too
# small answers 502 ITSELF, while Keycloak logs a successful request. The
# symptom therefore points at the application and the cause is at the proxy.
# Set the equivalent next to the Gateway, in that implementation's own object,
# BEFORE moving the host. NOTES.txt prints this in Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the PUBLIC route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the public hostname has one source of
# truth. ingress.domain is required in every mode in this chart anyway (the
# Deployment reads it as KC_HOSTNAME), so setting this list is only for
# publishing under a different or an ADDITIONAL name - which is how a
# migration from the Ingress runs: the HTTPRoute takes a second hostname
# while the Ingress keeps ingress.domain.
hostnames: []
admin:
# The admin route's own parent. null/empty falls back to
# httpRoute.parentRefs - the exact counterpart of ingress.admin.className
# falling back to ingress.className. "Which data path" is a property of the
# Gateway in this API rather than of the route, so an INTERNAL Gateway here
# is what an internal ingress class is in the other mode. Still not the
# access control the ingress.admin paragraph above is about.
parentRefs: []
# Empty means [ingress.admin.domain]. Required (via that value) while
# ingress.admin.enabled is true and httpRoute.enabled is true.
hostnames: []