keycloak: one hostname per realm, in both routing modes (#122)
The chart gains keycloak.realmHosts: a list rendering one routing object per entry - an Ingress with app-root, or an HTTPRoute whose Exact match on / carries a RequestRedirect filter to /realms/<realm>/account/. Empty by default, so nothing renders and no installation changes. keycloak: the new Configurable-values row, and the section 'Four more Ingress objects in this namespace, and they are not this chart's' is replaced by 'One hostname per realm' - the four objects are still named there, but now as the thing a migration would adopt rather than as a permanent neighbour. With the per-mode object table, the two required deviations (per-entry tlsSecretName, optional per-entry issuer), the six render aborts and what each prevents, the proxy-buffer loss in Gateway mode phrased for account-console traffic, and the migration order with its one unchecked question. Chart-Style-Guide: the third admissible Ingress shape, with both deviations stated as required rather than tolerated, and keycloak added to the index-naming rule beside homeassistant. CI-CD: 56 assertions over 21 charts becomes 60, keycloak 4 -> 8 plus two 'not checked' lines; and why the overlay does not repeat the entries that the install-test fixture already carries. Charts: the Ingress cell for keycloak. Lessons-Learned: 'The precise message that hid the structural one' - a per-entry check that fired before the mode-conflict abort and answered the wrong diagnosis, plus the note that a strict schema can make a template guard unreachable.
5 files changed
+103
-10
No files matched your search
+3
-1
@@ -153,7 +153,7 @@ Three things to know before touching it:
|
||||
- **What is deliberately not checked.** *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable and that claim is made nowhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing beyond that: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule carrying no `backendRefs` at all (legal for a filter-only rule such as a redirect) — all three are **reported as "not checked", never counted as passing**. Note that the two interact honestly: the redirect rule the pairing check demands is itself backend-less, so a chart that satisfies the check also earns one "not checked" line, and both verdicts are correct at once.
|
||||
- **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/<chart>.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that, a run in which **no assertion at all** held fails. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode); every other converted chart through its overlay.
|
||||
- **For a multi-app chart the overlay has a second job**: it must enter **every** shape the template has, or the branch nobody exercised is the one that breaks. `ci/httproute/freesailarr.yaml` moves one app onto a host of its own on top of the fixture's shared host, so the shared-host route *and* a per-app route both render — and it leaves `mediathekarr` published-off, which exercises the per-app selector in the negative direction as well. It also keeps qBittorrent **on** the shared host, which is what makes the prefix/redirect pairing fire for a real chart rather than cover nothing.
|
||||
- **And a multi-object overlay has to enter every object**, which group 3 made routine: `ci/httproute/keycloak.yaml` covers the public *and* the admin route (the admin parent falling back to the chart-global one, which is the branch an installation takes), and `ci/httproute/matrix-synapse.yaml` covers the homeserver *and* the delegation route — two hostnames, two paths and two **different** backends, which is the only overlay in the repo where the two `backendRefs` resolve to different Services.
|
||||
- **And a multi-object overlay has to enter every object**, which group 3 made routine: `ci/httproute/keycloak.yaml` covers the public *and* the admin route (the admin parent falling back to the chart-global one, which is the branch an installation takes), and `ci/httproute/matrix-synapse.yaml` covers the homeserver *and* the delegation route — two hostnames, two paths and two **different** backends, which is the only overlay in the repo where the two `backendRefs` resolve to different Services. Since #122 `ci/httproute/keycloak.yaml` also covers that chart's two **realm** routes, and it does so **without repeating them**: the two `keycloak.realmHosts` entries live in `ci/keycloak/test-values.yaml` and this overlay is applied on top of it, so one definition is installed as two Ingress objects by the install-test and rendered as two HTTPRoutes here. Defining them in the overlay instead would have left the Ingress half of that feature — the half carrying the required value and the convention deviation — with no coverage at all. The two entries are also the two Ingress forms, one with a `clusterIssuer` and one without, and what this test holds about those fields is their **inertness**: no `tlsSecretName`, `clusterIssuer` or per-entry annotation may appear anywhere in a rendered route. Those routes also bring a second rule *shape* — an `Exact` match carrying a `RequestRedirect` filter and no `backendRefs` at all, reported as "not checked" by design.
|
||||
- **Two overlays have to *supply* hostnames**, and for two different reasons. `ci/httproute/homeassistant.yaml`: that chart has no `ingress.domain`, so its hostnames come from `ingress.hosts`, whose default is the scaffold placeholder — one host with one root path. The overlay therefore sets two entries, the second with **two** paths including an `Exact` one, because that second entry is what proves the chart renders **one object per host entry** rather than one object with several hostnames. For that chart the overlay also **adds** coverage rather than trading it: `ingress.enabled` defaults to `false` there and the fixture does not switch it on, so the install-test had never exercised a routing object at all. Same situation as [garage](garage). `ci/httproute/rss-bridge.yaml`: that chart *has* a `httpRoute.hostnames` key, but it is **mandatory with no fallback** — no `ingress.domain` to fall back to, and it deliberately does not reuse `ingress.hosts`, whose entries carry `tlsSecretName` values that address nothing in Gateway mode. Its overlay sets **three** hostnames on **one** object, which is the shape its production installation has and the opposite of homeassistant's one-object-per-host. For that chart the overlay is the only automated coverage its Gateway mode has, because its install-test installs the Ingress mode — the ordinary situation across the sweep. See [rss-bridge](rss-bridge).
|
||||
- **A render-*failure* path cannot live here**, and two of group 3's guards are exactly that. This test only runs renders it expects to succeed, so homeassistant's `pathType: ImplementationSpecific` abort on a non-root path, and ytdlp-web-player's refusal to publish the app and its oauth2-proxy on one host, were exercised by hand instead. Where a guard matters, the chart's page says so and the PR records the exercise — a check that would have to assert a failing render belongs in the chart's own `fail`, not here.
|
||||
- **What the overlays do *not* cover is the text of `NOTES.txt`.** The Gateway branch of a chart's notes is *executed* by this test (a template error in it fails the job — verified by breaking one on purpose), but `helm template` does not print notes and the install-test installs in Ingress mode. So the notes' Gateway wording is reviewed by reading, never by a check.
|
||||
@@ -164,6 +164,8 @@ Group 3 took it to `56` assertions over `21` charts. Its 19 come from six charts
|
||||
|
||||
**`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — one route, so two assertions. It was the last chart left: 22 charts carried an Ingress template and 21 an HTTPRoute template, and the difference was exactly that one, held back by Gitea #51 rather than by anything about its shape.
|
||||
|
||||
**The realm hostnames of Gitea #122 add `4`**, and they are objects rather than a new check: `keycloak`'s share goes from 4 to 8 — one `parentRefs` and one `backendRefs` assertion for each of its two realm routes — plus one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on them: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. **The two rounds were measured separately and neither total includes the other**: `58/22` was read on the `rss-bridge` branch and `60/21` on the realm-hostname branch, which did not contain `rss-bridge`. So the **delta** is the figure to trust from either, and the running total is whatever the first run on `main` prints once both are in — read it there rather than adding these up, which is the same rule that produced both numbers.
|
||||
|
||||
**That chart's install-test also went green in the same PR**, after failing on this runner since the move — the event #51 had designated as its own signal, and the reason the chart was deliberately never added to `ci/excluded-charts.txt`. Pod `1/1 Ready` in 22 s, 0 restarts, no `Permission denied` in the job. The chart is ruled out as the cause by measurement rather than by argument: that PR's rendered defaults are **byte-identical** to `origin/main`, so the pod spec that installed is the one that had been failing. The runner is the implicated layer. It is **one** run, though, and nobody has identified what changed there — so the issue is not closed on the strength of a colour, and a fresh red would be the control case this result is missing. See [rss-bridge](rss-bridge).
|
||||
|
||||
The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
|
||||
|
||||
+8
-1
@@ -33,6 +33,13 @@ See [Versioning](Versioning) for the full rules. Short form: `version: <chart-se
|
||||
- `ingress.annotations` takes optional extra annotations, merged with the cert-manager annotation the chart always sets.
|
||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
||||
|
||||
**A set of objects publishing ONE backend under hostnames the chart does not own is the third admissible shape, and it pays for it with a per-entry secret name and a per-entry issuer (Gitea #122).** Where a chart's app serves several tenants out of one deployment and each wants a hostname of its own — `keycloak`'s realms, so far the only case — the hostnames come as a **values list** and the chart renders **one object per entry**, in **both** routing modes. Two rules above do not survive that, and both deviations are **required rather than tolerated**:
|
||||
|
||||
- **The TLS secret name is a per-entry value with no default.** These hostnames already have certificates and several entries typically share one (a wildcard for a zone), so a derived `tls-<release>-<chart>-…` name per entry would ask cert-manager for a second certificate for a name that is already covered — one ACME order per hostname, for nothing. An entry without one **aborts the render**: a TLS block with no secret serves the controller's default certificate and fails in a browser only.
|
||||
- **The cert-manager annotation becomes optional per entry**, and omitting it 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 it — the same deliberate omission the qBittorrent object in `freesailarr` makes. The two go together: a chart that invents the secret name cannot also leave the issuing to somebody else, which is why the name is *required* rather than defaulted.
|
||||
|
||||
Annotations are per entry too (the set is not uniform across the hostnames — three of `keycloak`'s four carry a proxy-buffer pair and one does not, which a chart-global block could not express), the per-entry `parentRefs` is the Gateway-mode counterpart of the per-entry class, and object names carry the **list index** by the rule below. The shape is not an excuse for a one-mode feature: it renders in both, or it is not admissible.
|
||||
|
||||
**A shared domain with sub-paths is the second admissible shape.** Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (`example.com/sonarr`, `example.com/radarr`). Rules for the shared shape: the domain is a single value (`ingress.host`); each app's path is its own value defaulting to `/<app>`; the paths render in one Ingress object with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the TLS secret name and **omitting** the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work. **In Gateway mode that extra object is not needed**: a `URLRewrite` filter is per rule, so the prefix-stripping app is simply one rule of the shared route — see below.
|
||||
|
||||
**Documented exception — homeassistant** keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `className` default follows the standard. **The exception extends to the Gateway mode**: with no `ingress.domain` there is no `httpRoute.hostnames` either, `ingress.hosts` is read in both modes, and the chart renders one route per entry — see the Gateway rules below.
|
||||
@@ -63,7 +70,7 @@ The rest of the rules, each against a failure that reports nothing:
|
||||
| **`parentRefs` mandatory in Gateway mode**, render abort on the empty list | a route without a parent attaches to nothing: created, schema-valid, rejected by nobody, routing nothing — no event, no condition, the only symptom a hostname that does not answer. |
|
||||
| **One source of truth for the hostname** — empty `hostnames` means `[ingress.domain]` | two values that have to agree drift apart. `ingress.domain` is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. A chart without an `ingress.domain` **omits `hostnames` entirely** and keeps its existing host values — and its schema rejects the key, so the absence is enforced rather than merely intended. Two such charts: `freesailarr` (`ingress.host` / `<app>.ingress.host`) and `homeassistant` (`ingress.hosts`, the flexible list). |
|
||||
| **Several routing objects where the Ingress had several hosts, or several rules with different backends** | `HTTPRoute.spec.hostnames` applies to the **whole object**, so one route can carry several hostnames only when they want the *same* rules. A chart whose hosts want different paths or different backends renders **one object per host**: `keycloak` (public and admin hostname), `matrix-synapse` (homeserver at `/`, delegation nginx at `/.well-known/matrix`), `homeassistant` (one per `ingress.hosts` entry). Where the hostnames differ in *kind* rather than merely in value, the per-object parent gets its own value falling back to the chart-global one — `httpRoute.admin.parentRefs`, `httpRoute.delegation.parentRefs`. That fallback is the counterpart of `ingress.<x>.className` falling back to `ingress.className`, because **"which data path" is a property of the Gateway in this API, not of the route**. |
|
||||
| **Index-based object names where the count is data-driven** | a hostname is not a dependable object-name fragment (the 63-character label limit plus the release prefix; a sanitised hostname can collide). `homeassistant` names its routes `…-httproute`, `…-httproute-1`, … by list index — with the consequence written down rather than discovered: **reordering the list renames routes**, so they are recreated rather than edited. Acceptable only because a route holds no state and no certificate. |
|
||||
| **Index-based object names where the count is data-driven** | a hostname is not a dependable object-name fragment (the 63-character label limit plus the release prefix; a sanitised hostname can collide). `homeassistant` names its routes `…-httproute`, `…-httproute-1`, … by list index, and `keycloak`'s realm objects follow it in both modes (`…-realm-ingress-1`, `…-realm-httproute-1`) — with the consequence written down rather than discovered: **reordering the list renames routes**, so they are recreated rather than edited. Acceptable only because a route holds no state and no certificate. |
|
||||
| **A chart that still renders its Ingress unconditionally gets an `ingress.enabled: true` first** | without a second value path there is nothing for the mutual-exclusion abort to name, and the Ingress plus its unconditional `required()` on `clusterIssuer` would keep rendering in Gateway mode — a value demanded for nothing. `paperless-ngx` was the last such chart and got the switch with group 2; the default keeps the render identical. |
|
||||
| **`NOTES.txt` names the active mode** and what it costs, as `keycloak` already does for its cases | a mode nobody can see from the outside |
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ grep -E '^(version|appVersion):' <chart>/Chart.yaml
|
||||
| [garage](garage) | Garage (S3-compatible object store) | StatefulSet + **two** PVCs | off by default | **one** node at `replication_factor = 1` — which applies to the metadata too, so `metadata_fsync`, the metadata snapshots and a second PVC for them are mandatory rather than optional; `FROM scratch` image, so no shell and no exec probes ever; the RPC secret has to be an env var; readiness is `/health`, liveness and startup deliberately are not |
|
||||
| [gotenberg](gotenberg) | Gotenberg | Deployment + Service | no | PDF conversion for [paperless-ngx](paperless-ngx); sets every limit explicitly |
|
||||
| [homeassistant](homeassistant) | Home Assistant | StatefulSet | upstream-style lists | the only chart with Helm tests; starts as root for s6-overlay |
|
||||
| [keycloak](keycloak) | Keycloak (OIDC / SAML identity provider) | Deployment, **2** replicas, **two init containers**, no PVC | yes, **two** (public + admin host) | **the admin hostname is not an access control** — it only decides which URLs the server generates, and upstream says so; `db`/`health-enabled`/`metrics-enabled` are *build* options, so the stock image would rewrite its own installation at start and the two init containers are what keeps `readOnlyRootFilesystem: true`; clustered via `jdbc-ping` over the database, so two releases on one database **merge**; first `PodDisruptionBudget` and first `quay.io` image in the repo |
|
||||
| [keycloak](keycloak) | Keycloak (OIDC / SAML identity provider) | Deployment, **2** replicas, **two init containers**, no PVC | yes, **two** (public + admin host) plus one per `keycloak.realmHosts` entry, empty by default | **the admin hostname is not an access control** — it only decides which URLs the server generates, and upstream says so; `db`/`health-enabled`/`metrics-enabled` are *build* options, so the stock image would rewrite its own installation at start and the two init containers are what keeps `readOnlyRootFilesystem: true`; clustered via `jdbc-ping` over the database, so two releases on one database **merge**; first `PodDisruptionBudget` and first `quay.io` image in the repo |
|
||||
| [matrix-synapse](matrix-synapse) | Matrix Synapse | StatefulSet + nginx delegation Deployment | yes | `ingress.domain` is served in `/.well-known/matrix/*` — federation depends on it |
|
||||
| [minecraft-router](minecraft-router) | itzg/mc-router | Deployment + ConfigMap + NodePort | no (TCP) | `mappings` required; the chart behind the `checksum/config` outage |
|
||||
| [minecraft-server](minecraft-server) | itzg/minecraft-server | StatefulSet + PVC | no | only the newest Java line is maintained; `image.tagOverride` picks an older JVM |
|
||||
|
||||
@@ -721,3 +721,17 @@ So the chart stayed in the matrix and the check stayed red, explicitly as the st
|
||||
- **Keep a known failure attached to the thing it is about.** Red-and-explained is a worse pipeline and a better instrument than green-and-skipped. That trade is only correct when the explanation is written down where the red will be seen, which is why the chart's page and the issue both carried it.
|
||||
- **A green that arrives on an unchanged input is worth more than a green that arrives with a fix**, because it localises the cause by elimination. Here the byte-identical render was produced for an unrelated reason — version-bump justification — and turned out to be the control case. Cheap evidence collected as a matter of routine is what makes an accident interpretable.
|
||||
- **One run is not a repair.** The colour changed; nobody has yet identified what changed on the runner. An intermittent effect needs several runs per side, so the issue stays open and a fresh red would be the missing control rather than a surprise. **Close an issue on a cause, not on a colour** — which was the standing instruction on this one before the green arrived, written precisely so that the green could not be mistaken for the answer.
|
||||
|
||||
## The precise message that hid the structural one
|
||||
|
||||
Gitea #122 added `keycloak.realmHosts`, a list rendering one routing object per entry in **both** modes. Its per-entry checks live in one helper so that neither mode is missing them, and the Ingress template calls that helper whenever `ingress.enabled` is true. Which looked right, and produced this: with `ingress.enabled` **and** `httpRoute.enabled` both true — the combination the chart has aborted over since the Gateway work, with a message about two data paths onto one hostname and Keycloak's absolute redirect URLs — the error a user got was
|
||||
|
||||
> `keycloak.realmHosts[0] (host a.example.com) has no tlsSecretName, and in Ingress mode it is required.`
|
||||
|
||||
Both statements are true. The second one is useless: the values file's defect is not a missing secret name, it is that both modes are on, and "in Ingress mode" is a claim about a configuration that is not coherent in the first place. Someone would have added the `tlsSecretName`, re-run, and met the real message one round later — or, worse, concluded the realm feature was the problem.
|
||||
|
||||
**The mechanism is ordering, and a chart does not get to rely on it.** Helm renders templates in an order a chart cannot pin, so "the structural guard runs first" is not something the Ingress template could assume about the HTTPRoute template. The fix is not ordering but **scope**: the Ingress template computes the realm list only while `ingress.enabled` is true **and** `httpRoute.enabled` is false, so in the incoherent combination it validates nothing and the only `fail` left is the one that names it.
|
||||
|
||||
**The transferable rule: a specific validation must not be reachable in a state that a structural validation already rejects.** Fine-grained checks are the ones that fire first by accident, because they sit closer to the data — and the more useful the message, the more convincing the wrong diagnosis. When adding a per-entry check to a chart that already aborts on a mode conflict, gate the new check on the conflict being absent, and verify it by rendering the conflicting combination rather than by reading the templates. It was found that way here: the combination was in the test matrix for the new value, and the output named the wrong thing.
|
||||
|
||||
A second, smaller piece from the same round, for anyone adding validation to a chart whose schema is strict: with `additionalProperties: false` and `required`/`minLength` declared, the schema rejects a missing `host` or `realm` **before** any template runs, so the template's own `fail` for those is unreachable through an ordinary `helm template`. Keep it — it is the second guard the repo's `required()` convention already asks for — but check it is not dead by rendering once with `--skip-schema-validation`. A guard nobody has ever seen fire can carry a typo in its own condition for years.
|
||||
+77
-7
@@ -12,7 +12,7 @@ What it costs is the thing to know before the first upgrade: **Keycloak supports
|
||||
| Versions | not listed on purpose — read the current pair off `keycloak/Chart.yaml`, see [Chart index](Charts) |
|
||||
| Workloads | Deployment, **2** replicas, **two init containers** — see [Why two init containers](#why-this-chart-has-two-init-containers) |
|
||||
| Storage | **none.** No PVC: Keycloak 26 keeps everything in the database, user sessions included (persistent sessions are the default; volatile ones are deprecated as of 26.8) |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`) — and **two objects in either mode**: the public hostname and the admin hostname (switchable off). The admin one is **not** an access control — see [the first trap](#the-admin-hostname-is-not-an-access-control). In Gateway mode the **proxy-buffer annotations this installation depends on are gone** and have no counterpart — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`) — and **two objects in either mode**: the public hostname and the admin hostname (switchable off), **plus one object per `keycloak.realmHosts` entry** — empty by default — which gives a realm a hostname of its own (see [One hostname per realm](#one-hostname-per-realm)). The admin one is **not** an access control — see [the first trap](#the-admin-hostname-is-not-an-access-control). In Gateway mode the **proxy-buffer annotations this installation depends on are gone** and have no counterpart — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
|
||||
| External dependencies | an external PostgreSQL. Nothing else |
|
||||
| Secrets | 2 × `OnePasswordItem`, four fields in total |
|
||||
| Network | ClusterIP Service on 8080 (`http`) and a **separate** ClusterIP Service on 9000 (`management`). The management port serves `/health/*` and `/metrics` **unauthenticated** and is in no Ingress. JGroups uses 7800 pod-to-pod; no NetworkPolicy yet |
|
||||
@@ -83,7 +83,7 @@ httpRoute:
|
||||
sectionName: https
|
||||
```
|
||||
|
||||
This renders two routes, `<release>-keycloak-httproute` and `<release>-keycloak-admin-httproute`, and nothing else of the routing path. `ingress.clusterIssuer` is not used; `ingress.domain` is **still required** because the Deployment reads it as `KC_HOSTNAME`. **Set the proxy-buffer equivalent at the Gateway before pointing DNS at it** — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
This renders two routes, `<release>-keycloak-httproute` and `<release>-keycloak-admin-httproute` — plus one `<release>-keycloak-realm-httproute…` per `keycloak.realmHosts` entry — and nothing else of the routing path. `ingress.clusterIssuer` is not used; `ingress.domain` is **still required** because the Deployment reads it as `KC_HOSTNAME`. **Set the proxy-buffer equivalent at the Gateway before pointing DNS at it** — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
|
||||
### What the 1Password items must contain
|
||||
|
||||
@@ -301,6 +301,7 @@ Four properties of this route that cost time if they are not known:
|
||||
| `keycloak.env` | list | `[]` | Extra environment variables, rendered through `tpl`; `value`, `secretKeyRef` and `configMapKeyRef` forms. **Additive, not overriding** — see [§10](Chart-Style-Guide#10-environment-variables). **A build option's variable name is refused outright** (`KC_DB`, `KC_HEALTH_ENABLED`, `KC_METRICS_ENABLED`, `KC_FEATURES`, …). Useful entries: `JAVA_OPTS_APPEND`, `KC_LOG_LEVEL`, `KC_SPI_*`, and `KC_TRUSTSTORE_PATHS` for a CA mounted through `keycloak.extraVolumes`. |
|
||||
| `keycloak.extraVolumes` | list | `[]` | Additional pod volumes, rendered **verbatim** — any Kubernetes volume source. The way a file gets into this pod at all; see [Getting a file into the pod](#getting-a-file-into-the-pod-themes-providers-extra-cas). It **places files and configures nothing**: a CA still needs `KC_TRUSTSTORE_PATHS`, a theme still needs to be selected per realm. |
|
||||
| `keycloak.extraVolumeMounts` | list | `[]` | Additional mounts, rendered verbatim and **twice — on the `build` init container as well as on the serving container**. Deliberate: a 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. **Never mount on `/opt/keycloak/themes` itself** — see [the trap](#a-volume-on-optkeycloakthemes-hides-the-bundled-themes). |
|
||||
| `keycloak.realmHosts` | list | `[]` | **One hostname per realm, rendered in *both* routing modes** (Gitea #122) — one Ingress per entry with `nginx.ingress.kubernetes.io/app-root`, or one `HTTPRoute` per entry whose `Exact` match on `/` carries a `RequestRedirect` filter. Empty renders nothing at all. Each entry: `host` and `realm` **required**, `tlsSecretName` **required in Ingress mode**, `clusterIssuer`, `annotations` and `className` optional there, `parentRefs` optional in Gateway mode. Object names carry the **list index**, so reordering renames and recreates them. See [One hostname per realm](#one-hostname-per-realm). |
|
||||
|
||||
### `ingress`
|
||||
|
||||
@@ -360,13 +361,82 @@ In Gateway mode both are gone, and the Gateway API has **no counterpart**: buffe
|
||||
|
||||
**Before moving this host: set the equivalent next to the Gateway**, in that implementation's own object, and raise it rather than merely matching 16k if long-lived tokens or SAML are in play.
|
||||
|
||||
### Four more Ingress objects in this namespace, and they are not this chart's
|
||||
### One hostname per realm
|
||||
|
||||
The live `keycloak` namespace carries four further objects — `keycloak-realm-bekemeyer-klein-ingress`, `keycloak-realm-jk-family-ingress`, `keycloak-realm-mail-ingress`, `keycloak-realm-muth-event-ingress` — each with `nginx.ingress.kubernetes.io/app-root` pointing at a realm's account console, and three of the four also carrying the proxy-buffer pair. They belong to the Helm release `jk-cluster-config-fleet-cd-keycloak-realm-ingress`, i.e. to the **`jk-cluster-config` repository**, and this chart renders exactly the two objects its own two Ingresses cover.
|
||||
`keycloak.realmHosts` is empty by default and renders nothing. Filled, each entry publishes **this release's Service** under a further hostname and sends that hostname's bare `/` to one realm's account console at `/realms/<realm>/account/`. It exists because Keycloak serves every realm from one deployment, so a realm that wants a name of its own needs a routing object and nothing else.
|
||||
|
||||
What they share with this chart is the **backend Service**, not the hostname: they publish `bk.id.bekemeyer-klein.de`, `id.mail.bekemeyer-klein.de`, `muth-event.id.bekemeyer-klein.de` and `id.klein.pub` — four further names, two of them subdomains of this chart's public host. So a wildcard listener for `*.id.bekemeyer-klein.de` would cover them, and a migration of the public host is **not finished** when this chart switches modes: those four objects stay Ingresses until `jk-cluster-config` moves them, and `app-root` has no Gateway-API counterpart either (a `RequestRedirect` filter on an `Exact` match is the nearest thing, and it would have to be written there, not here).
|
||||
It renders in **both** modes, which is the convention rather than a bonus: a routing *value* in this repository is mode-independent by design — `ingress.domain` is read in Gateway mode too, and `ingress.hosts` is read in both for the charts that have no `ingress.domain` — so a feature that rendered in one mode only would silently disappear when an installation switched ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)):
|
||||
|
||||
## Traps
|
||||
| Mode | Object per entry | How the bare `/` reaches the realm |
|
||||
|---|---|---|
|
||||
| Ingress | `<release>-keycloak-realm-ingress`, `…-realm-ingress-1`, … | `nginx.ingress.kubernetes.io/app-root`, which ingress-nginx renders as `if ($uri = /) { return 302 $scheme://$http_host<app-root>; }` (`nginx.tmpl:1000`, `controller-v1.14.5`) |
|
||||
| Gateway | `<release>-keycloak-realm-httproute`, `…-realm-httproute-1`, … | a rule matching `Exact: /` with a `RequestRedirect` filter, `ReplaceFullPath` to `/realms/<realm>/account/` and `statusCode: 302` written out; a second rule `PathPrefix: /` carries everything else to the Service |
|
||||
|
||||
```yaml
|
||||
keycloak:
|
||||
realmHosts:
|
||||
- host: realm-one.id.example.com
|
||||
realm: realm-one
|
||||
tlsSecretName: tls-id-wildcard-ingress # a certificate that already exists
|
||||
clusterIssuer: "" # issued elsewhere - no annotation
|
||||
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
|
||||
```
|
||||
|
||||
**One object per entry, never one object with several hostnames.** `spec.hostnames` applies to the whole `HTTPRoute` 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 same holds for the Ingress side, where `app-root` is per object.
|
||||
|
||||
**The realm does not follow from the hostname, and the chart does not try to derive it.** Both are given per entry. In the installation this was written for, one of the four hostnames serves a realm whose name shares nothing with it.
|
||||
|
||||
**Object names carry the list index**, not the realm — 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), the same reasoning as `homeassistant`'s per-host routes. So **reordering `keycloak.realmHosts` renames these objects and recreates them**, with a brief gap on that hostname; the TLS secret is not the chart's and survives it. `NOTES.txt` prints which index carries which hostname and realm, because the object name does not say.
|
||||
|
||||
#### Why the TLS secret is required and the issuer is not
|
||||
|
||||
This is the deviation from the repo's Ingress convention — one secret named `tls-<release>-<chart>-ingress`, cert-manager annotation always set — and it is **required rather than tolerated**:
|
||||
|
||||
| Field | Convention | Here, and why |
|
||||
|---|---|---|
|
||||
| `tlsSecretName` | derived from release and chart | **Per entry, required, 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 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. An entry without one **aborts the render** instead of emitting a TLS block that silently serves the controller's default certificate — a browser-only failure. |
|
||||
| `clusterIssuer` | always set | **Optional per entry, and empty 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. Set it only where the certificate is to be issued *for* this entry's own secret. Same deliberate omission as the qBittorrent object in [freesailarr](freesailarr). |
|
||||
| `annotations` | chart-global | **Per entry.** The set is not uniform: of the four hostnames this was written for, three carry the proxy-buffer pair and one does not, which one global block could not express. |
|
||||
|
||||
#### What the render refuses, and what each refusal prevents
|
||||
|
||||
| Abort | The failure it pre-empts |
|
||||
|---|---|
|
||||
| an entry without `host` | ingress-nginx reads an empty host as a **catch-all** for everything the controller serves that nothing else claims; an `HTTPRoute` with no hostnames attaches to every hostname of its listener. Both publish a realm's console under names nobody chose. |
|
||||
| an entry without `realm` | the target reads `/realms//account/` — a 404 from Keycloak behind a hostname, a certificate and a route that are all healthy. |
|
||||
| a `realm` containing `/` | it is interpolated into a URL path, so a slash retargets the redirect instead of being escaped. |
|
||||
| an entry without `tlsSecretName`, Ingress mode | a TLS block with no secret: the controller's default certificate, and a failure visible only in a browser. |
|
||||
| a host repeated, or equal to a hostname this release already publishes | in Ingress mode the admission webhook rejects the second claim on a host-and-path pair — under Fleet that leaves the bundle in `ErrApplied` until `forceSyncGeneration` is raised. In Gateway mode the webhook never fires and both objects are **accepted and merged**, so which redirect a client gets is decided by object age. |
|
||||
| a filled list with **both** modes off | no object at all: no Ingress, no route, no DNS answer, and nothing in the rendered release that reveals the list was ignored. |
|
||||
|
||||
The entry-shape checks sit in one helper (`keycloak.realmHosts`) so they exist in **both** modes — a check written in one routing template would not exist in the other. The Ingress template skips them while `httpRoute.enabled` is *also* true, so that "this entry needs a `tlsSecretName`" cannot answer a values file whose actual defect is that both modes are on; template execution order is not something a chart may rely on. Measured while writing it: without that condition, exactly that happened.
|
||||
|
||||
#### In Gateway mode these hostnames lose the proxy buffers too, and they feel it first
|
||||
|
||||
The per-entry `annotations` block is inert in Gateway mode, like `ingress.annotations` — see [What Gateway mode costs this chart](#what-gateway-mode-costs-this-chart-and-it-is-not-hypothetical). It bites harder on the realm hostnames than on the chart's own two, for a reason specific to what they are for: they exist to put somebody **in an account console**, so a logged-in session with large `Authorization` and `Set-Cookie` headers is the *normal* traffic on these names rather than the edge case it is on a login host that mostly serves anonymous requests. A buffer sized at the default 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 — nothing to grep for and nothing to un-set. `NOTES.txt` prints this in Gateway mode.
|
||||
|
||||
What the mode makes *cheaper* here: the entries' `tlsSecretName` values are not read either, and where several realm hostnames share a DNS zone **one wildcard listener** for that zone replaces one certificate per name.
|
||||
|
||||
#### Taking over hostnames another release serves today — the order, and the one unchecked question
|
||||
|
||||
This feature does **not** perform a migration and the chart does not know whether one is pending. Four such objects exist in the live `keycloak` namespace today — `keycloak-realm-bekemeyer-klein-ingress`, `keycloak-realm-jk-family-ingress`, `keycloak-realm-mail-ingress`, `keycloak-realm-muth-event-ingress` — owned by the Helm release `jk-cluster-config-fleet-cd-keycloak-realm-ingress`, i.e. by the **`jk-cluster-config` repository**, each pointing at this chart's Service under a hostname of its own. With `realmHosts: []` nothing about them changes, which is precisely why adopting them is a separate step.
|
||||
|
||||
**The order is not symmetric:**
|
||||
|
||||
1. **Remove the objects in the owning repository first**, and let that removal reach the cluster.
|
||||
2. **Then** install this chart with `keycloak.realmHosts` filled.
|
||||
|
||||
The window between the two is a short unreachability of those hostnames. The other order, or both at once, is worse: two Ingresses on one host and path are rejected by ingress-nginx's admission webhook, and under Fleet the bundle sits in `ErrApplied` until `forceSyncGeneration` is raised — measured, and written down in `ytdlp-web-player`'s collision `fail`. In Gateway mode the duplicate is accepted instead of rejected, which is quieter and no better.
|
||||
|
||||
**And the question nobody has answered yet:** whether anything in `jk-cluster-config` depends on those four objects **by name** — a Fleet bundle, a monitor, an `objectset` reference. It is **unchecked**; only the `keycloak` namespace was read, not the second repository. Clear it before step 1.
|
||||
|
||||
That a host can change routing mode at all without an outage is measured — `freesailarr.jk-dev.de` switched on 2026-10-13 with no downtime — but that case was a single `helm upgrade` within **one** release, which removed the Ingress and created the route so that both never existed at once. Here the objects live in **two** repositories and two releases, and that is where the window comes from: the precedent covers the mechanics, not the coordination.
|
||||
|
||||
### The admin hostname is not an access control
|
||||
|
||||
@@ -501,5 +571,5 @@ An overlay rather than a change to the install-test fixture, and that is a delib
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §11 for the PodDisruptionBudget convention this chart introduced
|
||||
- [Renovate](Renovate) — why this chart needs no version constraint, and the automerge decision
|
||||
- [Lessons learned](Lessons-Learned) — *The upgrade procedure the automerge did not know about*, *The application that rewrites its own installation at start* and *The mount that would have arrived after the build it was for*
|
||||
- [Lessons learned](Lessons-Learned) — *The upgrade procedure the automerge did not know about*, *The application that rewrites its own installation at start* and *The mount that would have arrived after the build it was for*, plus *The precise message that hid the structural one* from the realm-hostname round
|
||||
- [CI/CD](CI-CD)
|
||||
Reference in new issue
Block a user