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

+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# 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
# merging an app-version PR that changes the minor.
version: 1.2.0+up26.8.0
version: 1.3.0+up26.8.0
appVersion: "26.8.0"
maintainers:
+59 -1
View File
@@ -18,6 +18,7 @@ see of it.
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (.Values.ingress.admin | default dict).domain -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.settings" .)).realmHosts | default list -}}
{{- $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 }}.
@@ -45,16 +46,40 @@ ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true,
/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 $realmHosts }}
REALM HOSTNAMES - {{ len $realmHosts }} further name(s), each landing in ONE realm
The object names carry the LIST INDEX and not the realm, so this is the only
place that says which is which. Reordering keycloak.realmHosts therefore
renames these objects and recreates them.
{{ range $i, $entry := $realmHosts }}
https://{{ $entry.host }}
realm {{ $entry.realm }} (bare / -> /realms/{{ $entry.realm }}/account/)
object {{ $.Release.Name }}-{{ $.Chart.Name }}-realm-{{ if $httpRoute.enabled }}httproute{{ else }}ingress{{ end }}{{ if $i }}-{{ $i }}{{ end }}
{{- if not $httpRoute.enabled }}
cert {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested by THIS object ({{ $entry.clusterIssuer }}){{ else }}, NOT requested by this object - it must already exist or be issued elsewhere{{ end }}
{{- end }}
{{ end }}
{{- /*
No "and nothing publishes them" branch here, deliberately: a filled
keycloak.realmHosts with both modes off ABORTS the render in
keycloak.ingress.yaml, so this block can only ever be reached with a mode on.
A branch for an impossible state reads like a state somebody has seen.
*/ -}}
{{- end }}
{{ 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 -
This release renders {{ add (ternary 2 1 $adminHostname) (len $realmHosts) }} HTTPRoute(s) 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 }}
{{- range $i, $entry := $realmHosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $entry.host }} (realm {{ $entry.realm }})
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
@@ -69,6 +94,11 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
{{- range ($httpRouteAdmin.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (admin)
{{- end }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
{{- range ($entry.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (realm {{ $entry.realm }})
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
@@ -84,6 +114,12 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
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.
{{- if $realmHosts }}
The realm hostnames above are part of that: their tlsSecretName values
are not read in this mode either. Where several of them share a DNS zone,
ONE wildcard listener for that zone replaces one certificate per name -
which is the one thing this mode makes cheaper here rather than dearer.
{{- end }}
AND THE ONE YOU WILL NOT NOTICE UNTIL A LOGIN FAILS: THE PROXY BUFFERS.
@@ -109,6 +145,28 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
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.
{{ if $realmHosts }}
AND THE SAME LOSS ONCE MORE, ON THE REALM HOSTNAMES - where it is likelier to
be met rather than merely possible.
Each entry of keycloak.realmHosts carries its own annotations block in Ingress
mode, and in this mode every one of them is gone: {{ len $realmHosts }} hostname(s) here, none
of which can configure a proxy buffer through this chart any more. What the
entries CAN still express is the routing - the redirect into the realm is a
rule of the route and survives the mode change intact.
Why these hostnames are the awkward ones: they exist to put somebody in an
ACCOUNT CONSOLE. That is a logged-in session from the first request after the
redirect, so the large Authorization and Set-Cookie headers are the normal
traffic on these names rather than an edge case the way they are on a login
host that mostly serves anonymous requests. A buffer sized for the default
therefore fails here FIRST, and it fails as a 502 from the Gateway on a
hostname whose Ingress never had a chart value to point at - so there is
nothing to grep for and nothing to un-set.
Set the buffer equivalent for these hostnames too, in the Gateway
implementation's own object, and set it before the names resolve there.
{{- end }}
THE ADMIN HOSTNAME'S "INTERNAL CLASS" MOVES TO THE PARENT. What
ingress.admin.className does in the other mode, httpRoute.admin.parentRefs
+100 -1
View File
@@ -184,7 +184,8 @@ Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
"features" (list)
"cache" (dict "clusterName" nil)
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1)
"realmHosts" (list) -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}}
{{- end -}}
@@ -342,6 +343,104 @@ true
{{- end -}}
{{- end -}}
{{/*
THE REALM HOSTNAMES (Gitea #122) - validated here once, rendered twice.
WHAT THEY ARE. Keycloak serves every realm from one deployment, and a realm's
account console lives at /realms/<realm>/account/. A realm therefore gets a
hostname of its own by publishing THIS release's Service under that name and
sending the bare / to that path. The list is keycloak.realmHosts, it defaults
to empty, and an empty list renders nothing in either mode - the feature is
additive and the default render is byte-identical without it.
WHY THE VALIDATION IS SHARED AND NOT PER TEMPLATE. The same list feeds the
Ingress and the HTTPRoute template, so a check written in one of them is a check
that does not exist in the other mode - and the failure these checks are about
is a hostname that resolves, answers, and serves the wrong thing or nothing at
all. None of it is loud: an entry without a realm would render an Ingress whose
app-root annotation reads "/realms//account/", an entry without a host would
render an object with an empty host that ingress-nginx treats as a CATCH-ALL for
the whole controller, and an entry without a tlsSecretName would render a TLS
block with no secret, which serves the controller's default certificate and
fails in the browser only.
THE MODE DECIDES WHICH FIELDS ARE LOAD-BEARING, which is why it is a parameter
rather than read from the values here: tlsSecretName and annotations are Ingress
fields with no counterpart in the Gateway API (see keycloak.httproute.yaml), and
parentRefs is the other way round. A field demanded in a mode that ignores it is
the defect guideline 12 exists to remove, so each is required only where it is
read.
Input: dict "root" <the root context> "mode" "ingress"|"gateway"
Output: YAML of dict "entries" <the entries, as given, after validation>
*/}}
{{- define "keycloak.realmHosts" -}}
{{- $root := .root -}}
{{- $mode := .mode -}}
{{- $entries := (fromYaml (include "keycloak.settings" $root)).realmHosts | default list -}}
{{- if not (kindIs "slice" $entries) -}}
{{- fail (printf "keycloak.realmHosts must be a list or null, got %s (%v). One entry per realm hostname, each a mapping of host and realm plus the fields its routing mode reads - see values.yaml." (kindOf $entries) $entries) -}}
{{- end -}}
{{- $ingress := $root.Values.ingress | default dict -}}
{{- $ingressAdmin := $ingress.admin | default dict -}}
{{- $httpRoute := $root.Values.httpRoute | default dict -}}
{{- /*
THE HOSTNAMES THIS RELEASE ALREADY CLAIMS, collected so that a realm entry
cannot claim one of them a second time. Both modes punish it, differently and
neither of them clearly: under ingress-nginx the admission webhook rejects the
SECOND Ingress on the same host and path - which under Fleet leaves the bundle
in ErrApplied until forceSyncGeneration is raised - while two HTTPRoutes on one
listener and one hostname are MERGED by the implementation, so the duplicate is
accepted and which of the two identical rules serves a request is decided by
object age. The first is a deployment that stops, the second is a coin toss that
looks healthy.
*/ -}}
{{- $claimed := dict -}}
{{- if $ingress.domain -}}
{{- $_ := set $claimed (toString $ingress.domain) "ingress.domain, this release's public hostname" -}}
{{- end -}}
{{- if and (eq (include "keycloak.adminHostnameEnabled" $root) "true") $ingressAdmin.domain -}}
{{- $_ := set $claimed (toString $ingressAdmin.domain) "ingress.admin.domain, the administration console's hostname" -}}
{{- end -}}
{{- if eq $mode "gateway" -}}
{{- range ($httpRoute.hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.hostnames, the public route's explicit hostname list" -}}
{{- end -}}
{{- range (($httpRoute.admin | default dict).hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.admin.hostnames, the admin route's explicit hostname list" -}}
{{- end -}}
{{- end -}}
{{- $seen := dict -}}
{{- range $i, $entry := $entries -}}
{{- $at := printf "keycloak.realmHosts[%d]" $i -}}
{{- if not (kindIs "map" $entry) -}}
{{- fail (printf "%s must be a mapping with at least host and realm, got %s (%v). The list is a list of entries, not of hostnames - the realm cannot be derived from the hostname, and this installation is the proof: id.klein.pub serves the realm klein-family." $at (kindOf $entry) $entry) -}}
{{- end -}}
{{- $host := toString ($entry.host | default "") -}}
{{- $realm := toString ($entry.realm | default "") -}}
{{- if not $host -}}
{{- fail (printf "%s has no host (its realm is %q). An Ingress rule or an HTTPRoute without a hostname is not an error in either API: ingress-nginx treats the empty host as a CATCH-ALL and the object then answers every name the controller serves that nothing else claims, while an HTTPRoute with an empty hostnames list attaches to every hostname of its listener. Both publish this realm's account console across hostnames nobody chose. Set %s.host." $at $realm $at) -}}
{{- end -}}
{{- if not $realm -}}
{{- fail (printf "%s (host %s) has no realm. The realm is the whole point of the entry - it is what the bare / redirects to - and without it the redirect target would read /realms//account/, which Keycloak answers 404 while the hostname, the certificate and the route are all healthy. Set %s.realm, and read it off the realm in Keycloak rather than off the hostname: the two need not match, and in this installation they do not." $at $host $at) -}}
{{- end -}}
{{- if contains "/" $realm -}}
{{- fail (printf "%s has realm %q, which contains a slash. The value is interpolated into the URL path /realms/<realm>/account/, so a slash does not escape it - it silently retargets the redirect at a different path. Use the realm's name as Keycloak shows it." $at $realm) -}}
{{- end -}}
{{- if hasKey $seen $host -}}
{{- fail (printf "%s repeats the host %s, already used by keycloak.realmHosts[%v]. Two entries on one hostname render two objects with the same hostname and the same path: in Ingress mode the admission webhook rejects the second, in Gateway mode both are accepted and merged and the redirect a client gets - so the realm it lands in - depends on which object is older. Give each realm its own hostname, or drop the duplicate entry." $at $host (index $seen $host)) -}}
{{- end -}}
{{- $_ := set $seen $host $i -}}
{{- if hasKey $claimed $host -}}
{{- fail (printf "%s claims the host %s, which this release already publishes through %s. One hostname cannot carry both: a realm entry sends the bare / to /realms/%s/account/, and the hostname it collides with serves the login endpoints or the administration console at / instead. Pick a hostname of its own for the realm - that is what the feature is for - or publish the realm through the existing host without this entry." $at $host (index $claimed $host) $realm) -}}
{{- end -}}
{{- if and (eq $mode "ingress") (not (toString ($entry.tlsSecretName | default ""))) -}}
{{- fail (printf "%s (host %s) has no tlsSecretName, and in Ingress mode it is required. Unlike this chart's own two Ingresses the realm objects do NOT get the conventional tls-<release>-keycloak-ingress name: these hostnames live on certificates that already exist - a wildcard covering several of them, or one per name - and letting the chart invent a secret name per entry would ask cert-manager for a SECOND certificate for a name that is already covered, which costs an ACME order per host and buys nothing. So the name is operator data and has to be given. If the certificate is to be issued for this entry, name a secret of its own here and set %s.clusterIssuer as well." $at $host $at) -}}
{{- end -}}
{{- end -}}
{{- toYaml (dict "entries" $entries) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
+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 }}
+135
View File
@@ -46,6 +46,41 @@ 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" }}
{{- /*
THE REALM HOSTNAMES WITH NO MODE TO CARRY THEM (Gitea #122), checked here
because this is the template that owns ingress.enabled and because the check
has to run when NEITHER routing template emits anything. Both modes off is a
legitimate configuration - the chart then publishes nothing and NOTES.txt says
so - but a filled keycloak.realmHosts next to it is not: the list would be
accepted, validated, and rendered into no object at all, which is the one
outcome nobody can tell from a working install by looking at the release.
Emitting nothing is only correct when nothing was asked for.
*/ -}}
{{- if and (not .Values.ingress.enabled) (not (.Values.httpRoute | default dict).enabled) }}
{{- if (fromYaml (include "keycloak.settings" .)).realmHosts }}
{{- fail "keycloak.realmHosts is set while BOTH routing modes are off (ingress.enabled: false and httpRoute.enabled: false). The entries name hostnames and realms and there is no object to put them in: no Ingress, no HTTPRoute, no DNS answer, and nothing in the rendered release that reveals the list was ignored. Switch on the mode these hostnames are meant to be served by, or remove the entries - both are coherent, this combination is not." }}
{{- end }}
{{- end }}
{{- /*
Validated and bound OUTSIDE the ingress.enabled block, because a variable
declared inside an if-block does not exist after it and the realm objects below
need the same list. The helper is only CALLED in Ingress mode: it demands a
tlsSecretName per entry, which the Gateway mode neither reads nor should ask
for.
AND NOT WHEN httpRoute.enabled IS ALSO TRUE, which looks redundant next to
ingress.enabled and is not. That combination is the one keycloak.httproute.yaml
aborts over, with a message about the two data paths; template execution order
is not something a chart can rely on, so a realm check that fired here would
sometimes answer "this entry needs a tlsSecretName" to a values file whose
actual defect is that both modes are on. The specific message must not be able
to hide the structural one. Measured while writing this: without the second
condition, exactly that happened.
*/ -}}
{{- $realmHosts := list }}
{{- if and .Values.ingress.enabled (not (.Values.httpRoute | default dict).enabled) }}
{{- $realmHosts = (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "ingress"))).entries }}
{{- end }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -119,3 +154,103 @@ spec:
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
{{/*
A REALM HOSTNAME (Gitea #122), one Ingress object per keycloak.realmHosts entry.
Keycloak serves every realm from this one deployment, and a realm's account
console lives at /realms/<realm>/account/. An entry here gives a realm a
hostname of its own: the same Service as the two objects above, under a further
name, with the bare / sent to that realm's console by app-root. Gateway mode's
counterpart is an Exact match plus a RequestRedirect filter - see
keycloak.httproute.yaml.
THIS IS WHERE THE CHART DEVIATES FROM THE REPO'S INGRESS CONVENTION, and the
deviation is the expensive half of the feature rather than an oversight. The
convention (style guide §4) is one TLS secret per object named
tls-<release>-<chart>-ingress, with the cert-manager annotation ALWAYS set.
Neither holds here:
- THE SECRET NAME IS OPERATOR DATA, required per entry and never derived.
These hostnames live in zones whose certificates already exist, and several
of them typically share one: a wildcard for a zone covers every realm host
under it. A chart-derived name per entry would ask cert-manager for a fresh
certificate for a name a wildcard already covers - one ACME order per
hostname, for nothing.
- THE ISSUER IS OPTIONAL, and omitting it is the normal case rather than the
exception. cert-manager keys a Certificate by the secret it writes, so two
objects naming one secret and both carrying the annotation request the same
certificate twice. Exactly one object per secret may carry it - which is the
same deliberate omission the qBittorrent object in freesailarr makes, for
the same reason. Where an entry's certificate is to be issued FOR it, it
names a secret of its own and sets clusterIssuer; where the certificate
comes from elsewhere (a wildcard, a Certificate in cluster-config, another
release), clusterIssuer stays empty and this object only consumes the
secret.
The entry's annotations are merged last and per entry, not chart-globally,
because this is not a uniform set: of the four hostnames this installation runs
today, three carry the proxy-buffer pair and one does not. A chart-global
annotation block could not express that difference.
*/}}
apiVersion: networking.k8s.io/v1
kind: Ingress
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, written down rather than discovered: reordering keycloak.realmHosts
RENAMES these objects, so they are recreated rather than edited, and the
hostname is briefly unserved while that happens. The TLS secret is not the
chart's and survives it. NOTES.txt prints which index carries which hostname
and realm, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-ingress{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
annotations:
{{- /*
The counterpart of the Gateway mode's Exact-match RequestRedirect.
ingress-nginx renders this annotation 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 /, with every other path proxied unchanged.
*/}}
nginx.ingress.kubernetes.io/app-root: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- with $entry.clusterIssuer }}
cert-manager.io/cluster-issuer: {{ . | quote }}
{{- end }}
{{- with $entry.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- /*
The entry's own class, falling back to ingress.className - the same shape as
ingress.admin.className. A realm hostname can perfectly well belong to a zone
served by a different controller than the login host.
*/}}
ingressClassName: {{ $entry.className | default $.Values.ingress.className }}
rules:
- host: {{ $entry.host | quote }}
http:
paths:
{{- /*
The SAME backend as the two objects above, by port NAME as they do.
Everything but the bare / is proxied straight through; app-root
handles /.
*/}}
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ $entry.host | quote }}
secretName: {{ $entry.tlsSecretName | quote }}
{{- end }}
+42
View File
@@ -151,6 +151,48 @@
"extraVolumeMounts": {
"description": "Additional volume mounts, rendered verbatim and TWICE - on the build init container as well as on the serving container (Gitea #88). That is deliberate: a login theme is read at runtime, but an SPI provider JAR must be present while `kc.sh build` writes the provider registry, or it is never registered and nothing reports it. Items loosely typed for the same reason as extraVolumes. Never mount on /opt/keycloak/themes itself - it would hide the image's base, keycloak and keycloak.v2 themes; use a subpath /opt/keycloak/themes/<name>.",
"type": ["array", "null"]
},
"realmHosts": {
"description": "One hostname per realm (Gitea #122), rendered in BOTH routing modes: one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter to /realms/<realm>/account/. Empty by default and then renders nothing at all. One object per entry, named by list INDEX - so reordering the list renames and recreates them. host and realm are required and the realm is NOT derived from the hostname; the render also aborts on a repeated host and on a host this release already publishes, because one hostname cannot serve two different things at /. tlsSecretName is required in Ingress mode and never derived, and clusterIssuer is optional there - the documented deviation from the one-secret/always-annotate convention, so that a certificate already covering these zones is consumed rather than requested a second time. See values.yaml for the full reasoning and for the order a takeover of existing hostnames has to run in.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["host", "realm"],
"properties": {
"host": {
"description": "The hostname serving this realm. Must differ from every other entry's and from the hostnames this release already publishes.",
"type": "string",
"minLength": 1
},
"realm": {
"description": "The realm as Keycloak names it, interpolated into the redirect target /realms/<realm>/account/. Not derivable from the hostname - the two need not correspond and in the installation this was written for they do not. A slash in it aborts the render, because it would retarget the redirect instead of being escaped.",
"type": "string",
"minLength": 1
},
"tlsSecretName": {
"description": "The TLS secret this hostname's certificate lives in. REQUIRED IN INGRESS MODE and deliberately not derived from release and chart name: these zones already have certificates, often one wildcard for several entries, and a derived name would mean one further ACME order per hostname for a name that is already covered. Not read in Gateway mode - there the certificate sits on the Gateway's listener.",
"type": ["string", "null"]
},
"clusterIssuer": {
"description": "Optional cert-manager issuer for THIS object. Empty or absent renders no cert-manager annotation, which is the normal case: cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry the annotation. Same deliberate omission as the qBittorrent object in freesailarr. Not read in Gateway mode.",
"type": ["string", "null"]
},
"className": {
"description": "Optional ingress class for this object, falling back to ingress.className - the same shape as ingress.admin.className, because a realm hostname may belong to a zone served by another controller. Not read in Gateway mode, where the class is a property of the Gateway.",
"type": ["string", "null"]
},
"annotations": {
"description": "Extra annotations for THIS entry's Ingress, merged after the app-root and cert-manager annotations. Per entry rather than chart-global on purpose: the set is not uniform across hostnames - of the four this was written for, three carry the proxy-buffer pair and one does not. Free-form keys by nature, so this object stays open. INERT in Gateway mode, and for an account console that loses the proxy buffers in the case where large headers are the norm rather than the edge.",
"type": "object"
},
"parentRefs": {
"description": "Optional Gateway(s) for this entry's HTTPRoute, falling back to httpRoute.parentRefs - the counterpart of className above, because \"which data path\" is a property of the Gateway in this API. Not decoration here: realm hostnames sit in several DNS zones, so a chart-global parentRef pinning one sectionName would attach every realm route to one certificate's listener, and a route whose listener does not match its hostname is not accepted and simply does not answer. Not read in Ingress mode.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
}
}
}
},
+114
View File
@@ -614,6 +614,120 @@ keycloak:
extraVolumes: []
extraVolumeMounts: []
# ONE HOSTNAME PER REALM (Gitea #122). Empty by default, and an empty list
# renders NOTHING in either mode - the default render is byte-identical to a
# release without this feature.
#
# WHAT IT IS FOR. Keycloak serves every realm from this one deployment, and a
# realm's account console lives at /realms/<realm>/account/. An entry here
# gives one realm a hostname of its own: this release's Service published
# under a further name, with the bare / sent to that realm's console and
# everything else proxied through unchanged. It renders in BOTH routing modes
# - one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one
# HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter.
#
# ONE OBJECT PER ENTRY, never one object with several hostnames: an
# HTTPRoute's hostnames apply 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. The objects are named by LIST INDEX
# (<release>-keycloak-realm-ingress, -realm-ingress-1, ... and the same for
# -realm-httproute), which means REORDERING THIS LIST RENAMES THEM and they
# are recreated rather than edited. NOTES.txt prints the index-to-hostname
# mapping, because the object name does not carry it.
#
# THE REALM IS NOT DERIVABLE FROM THE HOSTNAME and the chart does not try:
# both are given per entry. In the installation this feature was written for,
# one of the four hostnames serves a realm whose name shares nothing with it.
#
# Fields, with the mode that reads each one:
#
# host (both) REQUIRED. The hostname. Must not repeat
# another entry's, and must not be a hostname
# this release already publishes
# (ingress.domain, ingress.admin.domain, an
# httpRoute hostname) - the render aborts on
# either, because one hostname cannot serve two
# different things at /.
# realm (both) REQUIRED. The realm as Keycloak names it. It
# is interpolated into /realms/<realm>/account/.
# tlsSecretName (Ingress) REQUIRED in Ingress mode, see below.
# clusterIssuer (Ingress) OPTIONAL, see below. Empty or absent = no
# cert-manager annotation on this object.
# annotations (Ingress) OPTIONAL, per entry. Merged last.
# className (Ingress) OPTIONAL. Falls back to ingress.className.
# parentRefs (Gateway) OPTIONAL. Falls back to httpRoute.parentRefs.
#
# realmHosts:
# - host: realm-one.id.example.com
# realm: realm-one
# tlsSecretName: tls-id-wildcard-ingress # a certificate that exists
# clusterIssuer: "" # issued elsewhere, see below
# annotations:
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
# - host: id.other.example.com
# realm: realm-two
# tlsSecretName: tls-id-other-ingress
# clusterIssuer: letsencrypt-issuer # issued for THIS object
#
# (Neutral examples on purpose. Realm names, hostnames and secret names are
# operator data and are not in this chart - they belong in the values file of
# the installation.)
#
# WHY THIS DEVIATES FROM THE REPO'S INGRESS CONVENTION, which is one TLS
# secret named tls-<release>-<chart>-ingress and the cert-manager annotation
# ALWAYS set (style guide §4). Neither holds for these objects, and both
# deviations are deliberate:
#
# - tlsSecretName is REQUIRED AND NEVER DERIVED. These hostnames live in
# zones whose certificates already exist, and several entries typically
# share one - a wildcard for a zone covers every realm host under it. A
# chart-derived name per entry would ask cert-manager for a fresh
# certificate for a name a wildcard already covers: one ACME order per
# hostname, for nothing. So the name is operator data, and an entry
# without one aborts the render in Ingress mode rather than rendering a
# TLS block that silently serves the controller's default certificate.
# - clusterIssuer is OPTIONAL, and leaving it empty is the normal case.
# cert-manager keys a Certificate by the secret it writes, so two objects
# naming one secret and both carrying the annotation request the same
# certificate twice. Exactly one object per secret may carry it - the same
# deliberate omission the qBittorrent object in freesailarr makes, for the
# same reason. Set it only where the certificate is to be issued FOR this
# entry's own secret.
#
# ANNOTATIONS ARE PER ENTRY and not chart-global, because the set is not
# uniform: of the four hostnames the installation this was written for runs
# today, three carry the proxy-buffer pair and one does not. One global block
# could not express that difference. In GATEWAY MODE this field is inert, like
# ingress.annotations - and for these hostnames that loss bites harder than
# for the chart's own two, because an account console session is exactly the
# large-header case: see the httpRoute block below and NOTES.txt.
#
# TAKING OVER HOSTNAMES THAT ANOTHER RELEASE SERVES TODAY - THE ORDER MATTERS,
# AND IT IS NOT REVERSIBLE WITHIN A DEPLOY. These objects may already exist in
# the cluster, created by a different repository and owned by a different Helm
# release. Filling this list then does not edit them, it adds a SECOND object
# for the same hostname and path, and the two orders are not equally bad:
#
# 1. Remove the objects in the owning repository FIRST and let that removal
# reach the cluster.
# 2. THEN install this chart with the list filled.
#
# The window between the two is a short unreachability of those hostnames.
# The other order, or both at once, is worse: ingress-nginx's admission
# webhook rejects the second claim on a host-and-path pair, and under Fleet
# that leaves the bundle in ErrApplied until forceSyncGeneration is raised -
# measured, and written down in ytdlp-web-player's collision fail. In Gateway
# mode the webhook never fires (it compares Ingresses with Ingresses), and the
# duplicate is accepted instead: two merged routes on one hostname, with
# object age deciding which redirect a client gets.
#
# AND ONE QUESTION THIS CHART CANNOT ANSWER, UNCHECKED AS OF THIS WRITING:
# whether anything in the owning repository depends on those objects BY NAME -
# a Fleet bundle, a monitor, an objectset reference. Clear that before step 1,
# not after.
realmHosts: []
ingress:
# The PUBLIC ingress - the login endpoint, the token endpoints, the account
# console. On by default, because an identity provider nothing can reach is