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:
1 parent
d36112aaf6
commit
d0f0d49a93
7 files changed
+470
-13
No files matched your search
@@ -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" .) -}}
|
||||
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
|
||||
{{- $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 -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
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,
|
||||
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" . }}
|
||||
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only
|
||||
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin
|
||||
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
|
||||
{{ else if $httpRoute.enabled }}{{ range $hostnames }} Public: https://{{ . }}
|
||||
{{ end }}{{ else }} Public: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
|
||||
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
|
||||
{{ 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 }}
|
||||
|
||||
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 }}
|
||||
WITHOUT authentication. It has a ClusterIP Service
|
||||
({{ .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 operator materializes one Secret key per item FIELD, so a missing or
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
|
||||
replicas, or the cluster silently becomes two islands.
|
||||
- No deny rule for /admin on the public Ingress, and no restriction of the
|
||||
administration REST API. That is reverse-proxy work - see the first trap.
|
||||
- No deny rule for /admin on the public Ingress{{ if $httpRoute.enabled }} or route{{ end }}, and no restriction of
|
||||
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.
|
||||
Reference in new issue
Block a user