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
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
274 lines
14 KiB
YAML
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 }}
|