{{- /* TWO INGRESS OBJECTS, AND THE SECOND ONE IS NOT A SECURITY BOUNDARY. The public one publishes the login endpoints, the token endpoints and the account console. The admin one publishes the SAME backend under a second hostname, so that the administration console - whose URLs Keycloak generates from hostname-admin - can live on an internal ingress class or in a different DNS zone. WHAT IT DOES NOT DO, because the value's name invites the opposite assumption. Upstream, verbatim: "Using the hostname-admin option does not prevent accessing the Administration REST API endpoints via the frontend URL specified by the hostname option. If you want to restrict access to the Administration REST API, you need to do it on the reverse proxy level." So /admin/realms/... stays reachable through ingress.domain, and an IP allow-list on the admin host restricts nothing by itself. This chart deliberately renders NO deny rule for /admin on the public Ingress. Two reasons, and the second is the one that decided it: the administration console loads assets and performs redirects across more paths than /admin, so a rule written from the path name breaks the console in ways that only show up in a browser; and a rule nobody has tested against a running console is a measure that looks like one, which is worse than an absent one. Whoever needs the restriction puts it on the proxy in front of the public host, where it can be tested. GUIDELINE 12.1, CHECKED IN BOTH DIRECTIONS: ingress.domain is read a second time outside this template - as KC_HOSTNAME on the Deployment - so it stays required even with ingress.enabled: false, and its required() lives in the keycloak.frontendUrl helper rather than here. clusterIssuer is read only in this file, so switching both Ingresses off frees it. BOTH OBJECTS HANG OFF ingress.enabled, AND THE ADMIN ONE DID NOT USED TO (fixed with the Gateway-API support, Gitea #114). ingress.admin.enabled is NOT an Ingress switch: it decides whether KC_HOSTNAME_ADMIN is set on the Deployment, i.e. whether the admin hostname is in play at all - so the admin Ingress used to render with ingress.enabled: false, which meant "ingress.enabled: false" did not mean "no Ingress" and clusterIssuer was still required for an object nobody asked for. Measured before the fix: `helm template -f ci/keycloak/test-values.yaml --set ingress.enabled=false` emitted kc-keycloak-admin-ingress and nothing else of the routing path. It also made Gateway mode impossible: switching httpRoute on leaves ingress.enabled false, and the admin Ingress would have gone on claiming the admin host next to the admin HTTPRoute - one host, two data paths, the exact conflict the mutual 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 metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress labels: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment annotations: cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }} {{- with .Values.ingress.annotations }} {{- toYaml . | nindent 4 }} {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} rules: - host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }} http: paths: - backend: service: name: {{ .Release.Name }}-{{ .Chart.Name }}-service port: name: http path: / pathType: Prefix tls: - hosts: - {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }} secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress {{- end }} {{- if and .Values.ingress.enabled $adminEnabled }} --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress labels: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment annotations: # Its own certificate, for its own host. A separate TLS secret # (tls--keycloak-admin-ingress) rather than a shared one, because # these are two different hostnames that may well sit on two different # ingress classes - the shared-secret form in the style guide is for # several objects on ONE host. cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }} {{- with ((.Values.ingress.admin | default dict).annotations) }} {{- toYaml . | nindent 4 }} {{- end }} spec: # Falls back to ingress.className when not set. An internal ingress class # here is the useful half of the admin hostname - and still not the access # control the comment at the top of this file is about. ingressClassName: {{ (.Values.ingress.admin | default dict).className | default .Values.ingress.className }} rules: - host: {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }} http: paths: # The SAME backend and the same path as the public Ingress. 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. - backend: service: name: {{ .Release.Name }}-{{ .Chart.Name }}-service port: name: http path: / pathType: Prefix tls: - hosts: - {{ 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//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---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; } (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 }}