feat(keycloak): one hostname per realm, in both routing modes
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
This commit is contained in:
jklein committed 2026-10-13 10:13:08 +00:00
1 parent 6517643cd6
commit 7a2eb29e9e
10 files changed
+629 -16

No files matched your search

+118 -9
View File
@@ -46,16 +46,54 @@ 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 REALM ROUTES, AND WHY THERE IS ONE OBJECT PER ENTRY (Gitea #122)
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.
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
@@ -74,6 +112,7 @@ YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
{{- 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
@@ -161,4 +200,74 @@ spec:
- 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 }}