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
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:
10 files changed
+629
-16
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user