feat: Gateway-API as an alternative to the Ingress in the six special shapes (group 3) #120

Merged
jklein merged 7 commits from feat/gateway-api-group-3 into main 2026-10-13 10:59:10 +02:00
42 changed files with 2945 additions and 137 deletions

No files matched your search

+28 -7
View File
@@ -173,7 +173,7 @@ Measured: 30 charts, **5 selectors verified in ~14 s**, no cluster. The fail pat
**The real CRDs in the install-test close none of these gaps**, which is the whole reason this job exists next to them: the Gateway-API schema happily accepts an empty `parentRefs` list, a `backendRefs` entry pointing at a Service that was never created, and a prefix-stripping rewrite with no redirect beside it. The CRDs prove the object is *well-formed*; this test proves it *points somewhere*, and *somewhere right*. The port half is a genuine new exposure rather than a hypothetical: an Ingress backend references its Service port **by name**, while `backendRefs` takes a `PortNumber` and cannot, so the number in the route and the port in the Service are kept in step by hand from now on. **The real CRDs in the install-test close none of these gaps**, which is the whole reason this job exists next to them: the Gateway-API schema happily accepts an empty `parentRefs` list, a `backendRefs` entry pointing at a Service that was never created, and a prefix-stripping rewrite with no redirect beside it. The CRDs prove the object is *well-formed*; this test proves it *points somewhere*, and *somewhere right*. The port half is a genuine new exposure rather than a hypothetical: an Ingress backend references its Service port **by name**, while `backendRefs` takes a `PortNumber` and cannot, so the number in the route and the port in the Service are kept in step by hand from now on.
The third check is a **pairing within one HTTPRoute object**, and the scope is deliberate: Gateway-API matching precedence is a property of a single route, so a redirect in a *different* route (a different hostname) says nothing about this one. Precedence also makes the pairing a question of existence rather than order — an `Exact` path match outranks every prefix match, so the redirect rule wins wherever it is emitted, and the rules' length-descending order stays a rendering convention. `freesailarr` is the first chart it applies to; `bulwark`, the second `rewrite-target` chart, meets the same check when group 3 of #114 lands, which is why the rule lives here and not in one chart's template. The third check is a **pairing within one HTTPRoute object**, and the scope is deliberate: Gateway-API matching precedence is a property of a single route, so a redirect in a *different* route (a different hostname) says nothing about this one. Precedence also makes the pairing a question of existence rather than order — an `Exact` path match outranks every prefix match, so the redirect rule wins wherever it is emitted, and the rules' length-descending order stays a rendering convention. `freesailarr` is the first chart it applies to — **and, after group 3, still the only one.** The line that used to stand here said `bulwark` was the second `rewrite-target` chart and would meet the same check when group 3 landed. Group 3 landed and it does not: **`bulwark` has one Ingress object, no `rewrite-target`, and cannot have one** — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path at all and its route is one rule at `/` with no `URLRewrite` filter. Every occurrence of the string `rewrite-target` in that chart is an explanation of *why it does not help*, and #114's measured table arrived at "`bulwark`, `freesailarr`" by grepping for the string rather than for the annotation. The check still runs over all charts for the reason below — the mistake arrives by copy into a chart whose own diff nobody reads for it — not because a second chart is known to need it.
Three things to know before touching it: Three things to know before touching it:
@@ -181,7 +181,7 @@ Three things to know before touching it:
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere 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 more: 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 with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; both verdicts are correct at once. - **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere 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 more: 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 with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; 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 the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered. - **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 the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered.
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brings it to **37 over 15** — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. 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. Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. **Group 3 takes it to `56` assertions over `21` charts**, which is the sweep finished except for the frozen `rss-bridge`. Group 3's 19 come from six charts and three of them contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay is the only one in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. 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.
### Install-test (PR pipeline stage 2) ### Install-test (PR pipeline stage 2)
@@ -367,7 +367,7 @@ These guidelines are **binding for every current and future chart** in this repo
- `ingress.className` selects the ingress class and defaults to `nginx`. - `ingress.className` selects the ingress class and defaults to `nginx`.
- `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets. - `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets.
- The TLS secret is named `tls-<release>-<chart>-ingress`. - The TLS secret is named `tls-<release>-<chart>-ingress`.
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). - **Documented exception — homeassistant, and it extends to the Gateway mode**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). Because it has **no `ingress.domain`**, it has **no `httpRoute.hostnames`** either — the second chart without that key after `freesailarr`, and the schema rejects it outright so the absence is enforced rather than merely intended. The hostnames and the paths come from `ingress.hosts`, which is therefore read in **both** modes and is not an Ingress-only value, and the chart renders **one `HTTPRoute` per entry** of that list with one rule per path of the entry. One object carrying several hostnames would be wrong rather than merely different: an `HTTPRoute`'s rules apply to *every* hostname of the object, and per-host paths are this chart's whole point. `ingress.className`, `ingress.tls` and `ingress.annotations` are not read in Gateway mode. Route names carry the entry's **index**, not its hostname (a hostname is not a dependable object-name fragment), so reordering `ingress.hosts` renames routes and therefore recreates rather than edits them — harmless, since a route holds no state and no certificate, but worth knowing before it looks like a bug. Its `pathType` default is the awkward one; see the `ImplementationSpecific` rule in the Gateway-API bullets below.
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The 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 `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work. - **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The 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 `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
- **The Gateway API is the second routing mode, and it is implementation-neutral by construction (Gitea #114).** A chart offers it through one values block, off by default: - **The Gateway API is the second routing mode, and it is implementation-neutral by construction (Gitea #114).** A chart offers it through one values block, off by default:
@@ -386,16 +386,37 @@ These guidelines are **binding for every current and future chart** in this repo
- **`parentRefs` is mandatory in Gateway mode**, enforced by a `fail` on the empty list. A route without a parent attaches to nothing and is simply inert — created, schema-valid, rejected by nobody, reported by nothing. - **`parentRefs` is mandatory in Gateway mode**, enforced by a `fail` on the empty list. A route without a parent attaches to nothing and is simply inert — created, schema-valid, rejected by nobody, reported by nothing.
- **One source of truth for the hostname.** Empty `hostnames` means `[ingress.domain]`; two values that have to agree drift apart. `ingress.domain` is therefore read in Gateway mode too — it names the release's public hostname and is not an Ingress switch. - **One source of truth for the hostname.** Empty `hostnames` means `[ingress.domain]`; two values that have to agree drift apart. `ingress.domain` is therefore read in Gateway mode too — it names the release's public hostname and is not an Ingress switch.
- **`NOTES.txt` says which mode is active**, as `keycloak` already does for its cases, and names what the mode costs. - **`NOTES.txt` says which mode is active**, as `keycloak` already does for its cases, and names what the mode costs.
- **What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy, so a chart that depends on one must print in its Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway. Five charts *set* one by default (`bulwark`, `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` — all but `bulwark` carry that disclosure since group 2), and `garage` is a sixth that *depends* on one without setting it: its `ingress.annotations` comment tells the operator to set `proxy-body-size: "0"` because ingress-nginx's 1 MB default truncates S3 uploads. Measured while sweeping group 1 — "sets no behaviour-bearing annotation" is not the same question as "needs none", and the sweep screens on the second one. `freesailarr` is the mirror image of `garage` and was measured in group 2: it sets no body-size annotation and needs none itself, but `ingress.annotations` is the only lever an operator has for one (qBittorrent takes torrent-file uploads), so its Gateway-mode `NOTES.txt` says that an annotation set there is gone. The same applies to auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all (`cyberchef`, `searxng`), and so does any annotation a chart documents as its only lever for a response header (`bentopdf`: `X-Robots-Tag`). - **What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy, so a chart that depends on one must print in its Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway. Charts that *set or recommend* one carry that disclosure: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` since group 2, `bulwark` and `matrix-synapse` since group 3. `garage` *depends* on one without setting it: its `ingress.annotations` comment tells the operator to set `proxy-body-size: "0"` because ingress-nginx's 1 MB default truncates S3 uploads. Measured while sweeping group 1 — "sets no behaviour-bearing annotation" is not the same question as "needs none", and the sweep screens on the second one. `freesailarr` is the mirror image of `garage` and was measured in group 2: it sets no body-size annotation and needs none itself, but `ingress.annotations` is the only lever an operator has for one (qBittorrent takes torrent-file uploads), so its Gateway-mode `NOTES.txt` says that an annotation set there is gone. The same applies to auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all (`cyberchef`, `searxng`), and so does any annotation a chart documents as its only lever for a response header (`bentopdf`: `X-Robots-Tag`).
- **`rewrite-target` is the one place where the Gateway API is less machinery than what it replaces, not a translation of it.** `rewrite-target` applies per **Ingress object**; a `URLRewrite` filter applies per **rule**. A chart serving several apps under one host, one of which needs its prefix stripped, therefore needs a *second* Ingress object for that app — which then has to repeat the other object's TLS secret name, omit the cert-manager annotation so only one certificate is requested (conditionally, for the case where no other object renders), and match on an `ImplementationSpecific` regex path whose capture group the rewrite refers to. In Gateway mode all four disappear: one route, one rule per app, the filter on one rule, a plain `PathPrefix`. Measured on `freesailarr` in group 2, the only chart here with a `rewrite-target`; `bulwark` is the second and is in group 3. - **Screen the LIVE object, not only the chart — group 3's lesson, and the generalisation of Gitea #118.** A chart can read as correct while the running system fills a gap of the chart with an annotation the **operator** set, and the Gateway mode drops `ingress.annotations`, so it drops the compensation with it. At #118 that was found *after* shipping, on `freesailarr`, where the annotation was a redirect nobody would have looked for in the chart. Group 3 measured it beforehand with read-only `kubectl` against the live cluster, and **four of its six charts carried such an annotation that the chart never sets**: `keycloak` (`proxy-buffer-size: 16k` and `proxy-buffers-number: 4`, on *both* of its Ingress objects), `matrix-synapse` (`proxy-body-size: 50m`, plus the `nginx.org/client-max-body-size` twin for the other nginx controller, plus a `configuration-snippet`), `stalwart` (the whole `enable-cors` block), `bulwark` (`proxy-body-size: 25m`). None of them appears in a `git grep` of this repository, because no template sets any of them — the only hits are the *documentation* that recommends two of them. **The measurement at the live object is part of the procedure, not a flourish**, and reading the chart alone is not a substitute for it.
- **And read the annotation's directive before calling it load-bearing.** `matrix-synapse`'s live `configuration-snippet` looks like exactly what Synapse's `x_forwarded` setting depends on — client IP for rate limiting, and federation. It is not: `more_set_headers` is the headers-more module's **output**-header directive ("Replaces (if any) or adds (if not any) the specified output headers"), the request-side one being `more_set_input_headers`. Those headers therefore go on the **response to the client**; Synapse never sees them, and what it reads with `x_forwarded: true` is the *request's* `X-Forwarded-For`, which ingress-nginx supplies itself. So the scariest-looking loss in group 3 is not a loss at all, and its `NOTES.txt` says in as many words **not** to re-create it at the Gateway and not to read its absence as the cause of a rate-limiting problem. The real loss on that chart is the body size. A reading of documented semantics, stated as such — nothing A/B-tested a running Synapse.
- **"No counterpart" is a claim to check against the pinned CRDs, and for CORS it is false.** `stalwart`'s live Ingress carries `enable-cors` with an allow-origin pointing at `bulwark`, the webmail chart in the *same group* — so losing it breaks a second chart, with a browser console error and nothing at all in the server's log. Measured by grepping the CRD set this repo pins (`ci/gateway-api-crds.sh`, gateway-api v1.6.3 standard channel): HTTPRoute's filters **do** carry a full `cors` field (GEP-1767) with `allowOrigins`/`allowMethods`/`allowHeaders`/`allowCredentials`/`maxAge`, and its documented level, verbatim from that CRD, is `Support: Extended`. `responseHeaderModifier` is in the same CRD and is **also Extended, not Core**. The chart still renders neither, for three reasons in order: it has **no CORS values** to render one from (the origin and the method list are the operator's, and the chart sets no CORS annotation in Ingress mode either, so a filter would exist in Gateway mode only — an asymmetry that is its own defect and its own piece of work); **Extended support is not support**, since a rejected route is rejected *whole* (the reasoning #119 reached for `statusCode`) and the observed failure is quieter still — Cilium accepts `filters.type: CORS` and forwards the preflight upstream anyway, i.e. `Accepted: True` with the behaviour absent; and **half a CORS is worse than none, with the translatable half being the useless one here** — the browser's preflight must be answered *by the gateway* (the CORS filter's own contract forbids passing it upstream) and a bare header filter cannot do that, while this installation's allowed headers include `Authorization`, which makes **every** request preflighted. Hence: configure CORS at the Gateway with that implementation's own support *before* moving the host, and test from the browser — `curl` sends no preflight, so it passes while the webmail fails.
- **`rewrite-target` is the one place where the Gateway API is less machinery than what it replaces, not a translation of it.** `rewrite-target` applies per **Ingress object**; a `URLRewrite` filter applies per **rule**. A chart serving several apps under one host, one of which needs its prefix stripped, therefore needs a *second* Ingress object for that app — which then has to repeat the other object's TLS secret name, omit the cert-manager annotation so only one certificate is requested (conditionally, for the case where no other object renders), and match on an `ImplementationSpecific` regex path whose capture group the rewrite refers to. In Gateway mode all four disappear: one route, one rule per app, the filter on one rule, a plain `PathPrefix`. Measured on `freesailarr` in group 2, and after group 3 it is still the **only** chart here with a `rewrite-target`: `bulwark` was listed as the second one by #114 and by this file, and it has none — see the HTTPRoute-test section for why the string occurs in that chart four times without the annotation ever being set.
- **`pathType: ImplementationSpecific` has no counterpart either, and it is a chart *default* in one place.** Ingress offers `Exact`, `Prefix` and `ImplementationSpecific`; the Gateway API offers `Exact`, `PathPrefix` and `RegularExpression`. The first two map cleanly. `ImplementationSpecific` means "ask the controller", so nothing preserves it — and it is the default in `homeassistant`'s `ingress.hosts`, the one chart keeping upstream's flexible host list. The rule group 3 settled on, and the split is not tidiness: on the path `/` it is rendered as `PathPrefix`, because every controller treats a root `ImplementationSpecific` path as a prefix match and the chart's own default therefore works in Gateway mode unchanged; on **any other path the render aborts**, because in ingress-nginx an `ImplementationSpecific` sub-path is a **regex** and `PathPrefix` is not one, so mapping it silently would change how the path matches while the render stayed green — the #118 class of defect exactly. `RegularExpression` is deliberately not offered as an escape hatch: its support level is Implementation-specific, which is what the first rule above forbids.
- **A value derived from `ingress.enabled` is a second thing the sweep has to look for, and it is not visible in the Ingress template.** Gateway mode sets `ingress.enabled: false` *by construction*, so any value computed from it flips — and where it stood for "is there a TLS-terminating proxy in front" or "is this app published at all", the flipped value is wrong while nothing fails. Two finds so far, both fixed by one helper reading `ingress.enabled OR httpRoute.enabled` rather than by a new value: - **A value derived from `ingress.enabled` is a second thing the sweep has to look for, and it is not visible in the Ingress template.** Gateway mode sets `ingress.enabled: false` *by construction*, so any value computed from it flips — and where it stood for "is there a TLS-terminating proxy in front" or "is this app published at all", the flipped value is wrong while nothing fails. Two finds so far, both fixed by one helper reading `ingress.enabled OR httpRoute.enabled` rather than by a new value:
- `sure` (group 1), whose `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL` would have gone `false` with TLS terminated at the Gateway: insecure cookies, no HSTS, `http://` links, green pod. Helper `sure.publishedOverTls`. - `sure` (group 1), whose `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL` would have gone `false` with TLS terminated at the Gateway: insecure cookies, no HSTS, `http://` links, green pod. Helper `sure.publishedOverTls`.
- `freesailarr` (group 2), whose `freesailarr.sharedIngressApps` was gated on `ingress.enabled` and is read by the `<APP>__SERVER__URLBASE` environment variables as well as by the routing objects. In Gateway mode that list would have come out **empty**, so Radarr, Sonarr and Prowlarr would have been told no base path while their routes served them under `/radarr`, `/sonarr`, `/prowlarr` — a UI whose assets and API calls are linked at the host root, behind a Ready pod. The two guards living in that helper (seerr/mediathekarr only at `/`, no duplicate paths on the shared host) would have gone unenforced in that mode for the same reason. Helper `freesailarr.published`. - `freesailarr` (group 2), whose `freesailarr.sharedIngressApps` was gated on `ingress.enabled` and is read by the `<APP>__SERVER__URLBASE` environment variables as well as by the routing objects. In Gateway mode that list would have come out **empty**, so Radarr, Sonarr and Prowlarr would have been told no base path while their routes served them under `/radarr`, `/sonarr`, `/prowlarr` — a UI whose assets and API calls are linked at the host root, behind a Ready pod. The two guards living in that helper (seerr/mediathekarr only at `/`, no duplicate paths on the shared host) would have gone unenforced in that mode for the same reason. Helper `freesailarr.published`.
- `ytdlp-web-player` (group 3), the one this file had listed as still open, and the find with the sharpest consequence. Its `ytdlp-web-player.validate` guard — which refuses to publish the application and the bundled oauth2-proxy on the **same host** — was gated on `ingress.enabled`, so in Gateway mode it went silent. **And that guard is needed more in Gateway mode, not less.** In Ingress mode ignoring it fails loudly: ingress-nginx's admission webhook rejects the second Ingress and the Fleet bundle lands in `ErrApplied`. In Gateway mode **the webhook never fires** — it compares Ingress objects with Ingress objects, and an `HTTPRoute` is a different kind — so nothing rejects anything, both objects stay valid, and one hostname is served over two data paths that are *not* equivalent: the proxy's Ingress goes through **authentication**, the chart's `HTTPRoute` goes straight to the application. Which one a client reaches depends on what DNS resolves to, so it is an authentication bypass that comes and goes with a record, reported by nothing. Helper `ytdlp-web-player.published`, and the Gateway-mode abort carries its own message saying exactly that.
Still open on this count: `ytdlp-web-player`, which reads `.Values.ingress.enabled` outside its Ingress template. All three are now closed, and the screen was run over all six charts of group 3 rather than only the suspected one.
- **A subchart does not come along, and that has to be said where somebody will look for it.** `ytdlp-web-player`'s oauth2-proxy dependency has **no Gateway-API support** and this repo does not patch upstream charts (see "Chart dependencies"), so it renders an Ingress or nothing. In the *recommended* configuration — the proxy in the data path, owning the host, the chart's own routing object off — the object that publishes the release is therefore **the subchart's Ingress in both of the chart's modes**, and setting `httpRoute.enabled` there changes nothing about how the host is served. The chart's own route is for the proxy-disabled case or for the application on a host of its own, which is the documented unauthenticated bypass. Stated in the template header, in `values.yaml`, in the schema and in the proxy branch of `NOTES.txt`, because an operator migrating hosts would otherwise set the value and wonder why nothing moved.
- **CI**: either the chart's install-test fixture installs in Gateway mode, or it gets a render-only overlay at `ci/httproute/<chart>.yaml` — otherwise `ci/run-httproute-match-test.sh` is green over nothing for it, which that test fails on. An overlay for a multi-app chart 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` gives one app a host of its own so both the shared-host route and the per-app route render. - **CI**: either the chart's install-test fixture installs in Gateway mode, or it gets a render-only overlay at `ci/httproute/<chart>.yaml` — otherwise `ci/run-httproute-match-test.sh` is green over nothing for it, which that test fails on. An overlay for a multi-app chart 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` gives one app a host of its own so both the shared-host route and the per-app route render.
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Pilot: `cyberchef`. Group 1 — the charts rendering one Ingress with no behaviour-bearing annotation — followed: `bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`. Group 2 — the charts whose behaviour hangs off an nginx annotation — followed: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` (all four `proxy-body-size`) and `freesailarr` (`rewrite-target`). That leaves **seven** charts: the six of group 3 — the special shapes (`homeassistant` is the documented ingress exception, `keycloak` and `bulwark` have two Ingresses each, `matrix-synapse` renders in a loop, `stalwart` renders per node group, `ytdlp-web-player` hangs off the oauth2-proxy subchart) — and `rss-bridge`, which is frozen for changes (#51) and is why #114 cannot be closed while #51 is open. `garage` was one of the named special cases and is done: it ships `ingress.enabled: false`, so both of its modes are off by default and its route is exercised by a render overlay rather than by the install-test, which keeps its Ingress. `paperless-ngx` was the one chart in the repository that still rendered its Ingress **unconditionally**; group 2 gave it an `ingress.enabled` switch defaulting to `true`, because without a second value path there is nothing for the mutual-exclusion abort to name and the `required()` on `clusterIssuer` would have kept demanding a value the Gateway mode does not use. Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Pilot: `cyberchef`. Group 1 — the charts rendering one Ingress with no behaviour-bearing annotation — followed: `bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`. Group 2 — the charts whose behaviour hangs off an nginx annotation — followed: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` (all four `proxy-body-size`) and `freesailarr` (`rewrite-target`). **Group 3 — the special shapes — finishes the sweep**: `homeassistant`, `keycloak`, `bulwark`, `matrix-synapse`, `stalwart`, `ytdlp-web-player`. That leaves exactly one chart, `rss-bridge`, which is frozen for changes (#51) and is why **#114 cannot be closed while #51 is open**.
**Three of #114's descriptions of the group-3 shapes did not survive contact with the charts**, and the corrected list is worth having here because it is the one somebody will copy from:
| #114 said | Measured |
|---|---|
| `keycloak` and `bulwark` have **two** Ingresses each | `keycloak` has two (public and admin hostname) and gets two routes. **`bulwark` has one.** |
| `bulwark` is the second `rewrite-target` chart | It has none and cannot have one — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path and its route is one rule at `/`. The grep that produced the claim hit the chart's *refutations* of the idea. |
| `matrix-synapse` renders its Ingresses **in a loop over several apps** | That is `freesailarr`. `matrix-synapse` renders **one** Ingress object with **two host rules** — the homeserver at `/` and the delegation nginx at `/.well-known/matrix` — which has to become **two** `HTTPRoute` objects, because `spec.hostnames` applies to the whole object and these two want different paths and different backends. |
| `stalwart` renders **per node group** | Its Services and StatefulSets do; the routing object does not and must not. One Ingress targets exactly `ingress.nodeGroup`'s own http Service, and the route keeps both of that template's guards — the management surface is not safe to spread over a group that may not run the management tasks. |
Three further group-3 specifics: **`keycloak`'s admin Ingress did not hang off `ingress.enabled`** before the sweep (`ingress.admin.enabled` gates it, and that value is not an Ingress switch — it also sets `KC_HOSTNAME_ADMIN`), so `ingress.enabled: false` did not mean "no Ingress" and the object would have gone on claiming the admin host next to the admin route; it is gated on both now, which leaves the default render unchanged and only alters a combination that could not be coherent. `httpRoute.admin.parentRefs` falls back to `httpRoute.parentRefs` exactly as `ingress.admin.className` falls back to `ingress.className`, because **"which data path" is a property of the Gateway in this API, not of the route** — an internal Gateway there is what an internal ingress class is in the other mode. And **`stalwart` is the only chart in the sweep that still renders a `Certificate` in Gateway mode**: its mail certificate covers STARTTLS on 25 and implicit TLS on 465/993, which pass through neither an ingress controller nor a Gateway, so `ingress.domain` stays required there and the object must not be removed as part of a migration — deleting it drops SMTP and IMAP back to a self-signed certificate while every Kubernetes object stays green.
`garage` was one of #114's named special cases and was done in group 1: it ships `ingress.enabled: false`, so both of its modes are off by default and its route is exercised by a render overlay rather than by the install-test, which keeps its Ingress. `homeassistant` is the same case and the same treatment — its `ingress.enabled` default is `false` too, so group 3's overlay for it **adds** coverage the install-test never had rather than trading one object for another. `paperless-ngx` was the one chart in the repository that still rendered its Ingress **unconditionally**; group 2 gave it an `ingress.enabled` switch defaulting to `true`, because without a second value path there is nothing for the mutual-exclusion abort to name and the `required()` on `clusterIssuer` would have kept demanding a value the Gateway mode does not use.
**One place where the Gateway API is the better fit, found in group 3**: Matrix delegation. The server name is normally an apex domain that already serves a website, and under ingress-nginx two Ingress objects cannot both claim one hostname — the admission webhook rejects the second — which is why `matrix-synapse` has to carry the `/.well-known/matrix` rule *inside* its own single Ingress object. HTTPRoutes attached to the same listener for the same hostname are **merged** by the implementation, longest match winning, so in Gateway mode that route can sit beside whatever owns the apex without either side knowing about the other.
- **Documented exception — freesailarr, and it extends to the Gateway mode**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain` — and therefore **no `httpRoute.hostnames`** either, the only chart without that key. A chart-global list of hostnames could not say which of the ten apps it belonged to, so the hostnames stay where they already are, in `ingress.host` and `<app>.ingress.host`; both are read by both modes and neither is an Ingress switch. In Gateway mode the per-app shape renders one `HTTPRoute` per app (`<release>-<chart>-<app>-httproute`) and the shared shape exactly one (`<release>-<chart>-httproute`) with one rule per app — qBittorrent included, since its prefix-stripping rewrite is a per-rule filter there, and it is the one app contributing **two** rules: the rewrite plus a 301 from the bare, slashless prefix to the slashed form (Gitea #118). Without that redirect the slashless path is stripped to `/`, answered 200, and the page's relative links resolve to the root of the shared host — a different app. **The Ingress mode does not render it and cannot**: nginx offers only a `configuration-snippet` annotation, which is ignored unless the controller runs with `allow-snippet-annotations=true` (off by default since ingress-nginx 1.9, and it means accepting annotation risk level `Critical`), so the chart documents the snippet in `values.yaml` instead of shipping a no-op. It is the one place where the Gateway mode closes a gap rather than merely replacing machinery. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app. - **Documented exception — freesailarr, and it extends to the Gateway mode**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain` — and therefore **no `httpRoute.hostnames`** either, the only chart without that key. A chart-global list of hostnames could not say which of the ten apps it belonged to, so the hostnames stay where they already are, in `ingress.host` and `<app>.ingress.host`; both are read by both modes and neither is an Ingress switch. In Gateway mode the per-app shape renders one `HTTPRoute` per app (`<release>-<chart>-<app>-httproute`) and the shared shape exactly one (`<release>-<chart>-httproute`) with one rule per app — qBittorrent included, since its prefix-stripping rewrite is a per-rule filter there, and it is the one app contributing **two** rules: the rewrite plus a 301 from the bare, slashless prefix to the slashed form (Gitea #118). Without that redirect the slashless path is stripped to `/`, answered 200, and the page's relative links resolve to the root of the shared host — a different app. **The Ingress mode does not render it and cannot**: nginx offers only a `configuration-snippet` annotation, which is ignored unless the controller runs with `allow-snippet-annotations=true` (off by default since ingress-nginx 1.9, and it means accepting annotation risk level `Critical`), so the chart documents the snippet in `values.yaml` instead of shipping a no-op. It is the one place where the Gateway mode closes a gap rather than merely replacing machinery. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
### 5. Security hardening ### 5. Security hardening
+1 -1
View File
@@ -21,7 +21,7 @@ type: application
# duplicated here. The short version: eight tag shapes, six of them things # duplicated here. The short version: eight tag shapes, six of them things
# nobody should pin, and plain `docker` versioning excludes all six - so that # nobody should pin, and plain `docker` versioning excludes all six - so that
# block deliberately carries NO versioningTemplate. # block deliberately carries NO versioningTemplate.
version: 1.0.0+up1.12.0 version: 1.1.0+up1.12.0
appVersion: "1.12.0" appVersion: "1.12.0"
maintainers: maintainers:
+87 -10
View File
@@ -1,4 +1,16 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "bulwark.settings" .) -}} {{- $settings := fromYaml (include "bulwark.settings" .) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}} {{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}} {{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}} {{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
@@ -11,17 +23,75 @@ The messages and the accounts belong to the Stalwart server at
If that server is away, this release is still Ready and still green - see If that server is away, this release is still Ready and still green - see
"WHAT THE PROBES DO NOT TELL YOU" below. "WHAT THE PROBES DO NOT TELL YOU" below.
ENDPOINTS ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if .Values.ingress.enabled }} Web: {{ $url }} {{ if or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
{{ else }} Web: no Ingress (ingress.enabled: false) - in-cluster only {{ else }} Web: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
both false) - in-cluster only
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }} {{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }}
{{ if .Values.ingress.enabled }} Admin: {{ $url }}/admin/login {{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
{{ else }} Admin: /admin/login on the in-cluster address above. With no Ingress, reach it with {{ else }} Admin: /admin/login on the in-cluster address above. With nothing published, reach it with
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }} kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }}
and open http://localhost:3000/admin/login and open http://localhost:3000/admin/login
{{ end }}{{ if .Values.ingress.enabled }} {{ end }}{{ if or .Values.ingress.enabled $httpRoute.enabled }}
Bulwark is served at the ROOT of that host and cannot be moved under a path: Bulwark is served at the ROOT of that host and cannot be moved under a path:
NEXT_PUBLIC_BASE_PATH is compiled into the image. NEXT_PUBLIC_BASE_PATH is compiled into the image.{{ if $httpRoute.enabled }} That is also why the
HTTPRoute carries no URLRewrite filter - there is no prefix to strip.{{ end }}
{{ end }}{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders ONE HTTPRoute and NOTHING else of the routing path - no
Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
Three things therefore belong to the cluster, and if any is missing the
hostname above simply does not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
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.
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
In Ingress mode an operator sets nginx.ingress.kubernetes.io/proxy-body-size
through ingress.annotations - this installation runs with 25m - because
nginx's default of 1m rejects almost every attachment. IN THIS MODE THAT IS
GONE: the Gateway API has no body-size field, it is implementation policy
rather than routing, and this chart renders no policy object on purpose (it
would bind the chart to one Gateway implementation).
So the limit in force is THE GATEWAY'S, whatever it happens to be, and no
value of this chart has any say in it. The failure mode on a webmail client
is worth picturing before it happens: a user has written the mail, attached
the file and pressed send, and the 413 comes back FROM THE PROXY. It reads
like Bulwark being broken, nothing mentions a limit, and the draft is the
thing at risk.
Set the equivalent next to the Gateway, in your implementation's own object,
BEFORE you point DNS at it - and size it to what the MAIL SERVER accepts
(the Stalwart release above), not to what looks generous here. A webmail
client that accepts a 40 MB attachment the mail server will refuse has only
moved the failure later.
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
{{ end }} {{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD. A missing or misnamed The operator materializes one Secret key per item FIELD. A missing or misnamed
@@ -244,9 +314,16 @@ WHAT THIS CHART DOES NOT RENDER
- No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid - No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid
every voluntary eviction and make `kubectl drain` hang forever on a pod every voluntary eviction and make `kubectl drain` hang forever on a pod
nothing may move (README, guideline 11). nothing may move (README, guideline 11).
- No sub-path Ingress, and no value that could add one - the base path is - No sub-path Ingress and no sub-path route, and no value that could add
compiled into the image. one - the base path is compiled into the image. Hence also no URLRewrite
filter in Gateway mode.
- No proxy-body-size annotation. Attachment uploads go through the Ingress and - No proxy-body-size annotation. Attachment uploads go through the Ingress and
nginx's default of 1m rejects larger ones with a 413 that reads like an nginx's default of 1m rejects larger ones with a 413 that reads like an
application fault; the right number is the mail server's limit, which this application fault; the right number is the mail server's limit, which this
chart does not know. Set it in ingress.annotations. chart does not know. Set it in ingress.annotations{{ if $httpRoute.enabled }} - except that in GATEWAY
MODE there is no annotation to set and no counterpart at all, which is the
section above{{ end }}.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
+26 -9
View File
@@ -447,17 +447,34 @@ for the first time during a real `helm install`. A guard that only runs in
production is worse than no guard. production is worse than no guard.
The honest position for THIS chart, checked in both directions as guideline 12.1 The honest position for THIS chart, checked in both directions as guideline 12.1
asks: nothing outside the Ingress template FUNCTIONALLY reads ingress.domain. asks, AND CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): it used to say
Bulwark derives its own public URL from the request origin and takes no base-URL that nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
variable, so with ingress.enabled: false the release is complete without a That was true and is no longer - bulwark.httproute.yaml reads it as the route's
domain - the calibre-web-automated case, where the gate frees both values. The hostname, with a required() of its own. What still holds is the half that made
enforcement therefore sits entirely in bulwark.ingress.yaml, where it is reached the claim worth making: Bulwark derives its own public URL from the request
by every render that has the Ingress on (the default), and this helper renders a origin and takes no base-URL variable, so with BOTH modes off the release is
visible placeholder instead of failing. complete without a domain - the calibre-web-automated case, where the gate
frees both values. The enforcement sits in whichever routing template is
active; this helper still renders a visible placeholder instead of failing,
because `helm template` does not render NOTES.txt.
IN GATEWAY MODE THE PUBLIC URL IS THE ROUTE'S HOSTNAME, not ingress.domain, and
the two differ exactly when httpRoute.hostnames is set explicitly - which is
how a migration off the Ingress runs. Printing ingress.domain there would hand
the operator an OIDC redirect URI for the host being migrated AWAY from, and
that failure surfaces as a rejected login at the identity provider rather than
as anything about this chart. So the helper follows the active mode.
*/}} */}}
{{- define "bulwark.publicUrl" -}} {{- define "bulwark.publicUrl" -}}
{{- if .Values.ingress.domain -}} {{- $httpRoute := .Values.httpRoute | default dict -}}
https://{{ .Values.ingress.domain }} {{- $host := .Values.ingress.domain -}}
{{- if $httpRoute.enabled -}}
{{- $host = (first ($httpRoute.hostnames | default list)) | default .Values.ingress.domain -}}
{{- end -}}
{{- if $host -}}
https://{{ $host }}
{{- else if $httpRoute.enabled -}}
https://<neither httpRoute.hostnames nor ingress.domain SET>
{{- else -}} {{- else -}}
https://<ingress.domain NOT SET> https://<ingress.domain NOT SET>
{{- end -}} {{- end -}}
+115
View File
@@ -0,0 +1,115 @@
{{/*
HTTPRoute - the Gateway-API alternative to bulwark.ingress.yaml (Gitea #114).
ONE ROUTE, ONE RULE, AT THE ROOT - and the reason is the same one that makes
the Ingress rule permanently "/": NEXT_PUBLIC_BASE_PATH is compiled into
Bulwark's bundle, so the application cannot be served under a sub-path at all.
Which is also why THIS CHART RENDERS NO URLRewrite FILTER and needs none. That
differs from what #114's measured table and the header of
ci/run-httproute-match-test.sh both expected of this chart, so it is worth
writing down where it will be read:
- This chart has ONE Ingress object, not two (keycloak has two).
- This chart has NO rewrite-target annotation and CANNOT have one. Every
occurrence of the string "rewrite-target" in this chart - values.yaml, the
schema, the Ingress template - is an explanation of why it does not help:
the bundle's own asset and API URLs are built for the root, so stripping a
prefix at the proxy yields a page that loads and then fetches everything
from the wrong place. A `git grep rewrite-target bulwark/` finds those
refutations and counts them as occurrences, which is how the expectation
arose.
- Consequently the #118 prefix/redirect assurance in
ci/run-httproute-match-test.sh does not apply here: with no URLRewrite on a
prefix other than "/", its precondition is never met. The bare-prefix
redirect that freesailarr needs has nothing to redirect here.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object - the subset that looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
listeners, in cluster-config.
WHAT THIS MODE DROPS FOR THIS CHART, MEASURED ON THE LIVE OBJECT
On 2026-10-12 the live Ingress (jk-cluster, namespace bulwark,
webmail.bekemeyer-klein.de) carried
nginx.ingress.kubernetes.io/proxy-body-size: 25m
The CHART does not set it - values.yaml recommends exactly that value and
explains why the chart cannot pick it (the right number is the mail server's,
which this chart does not know), so the 25m is the operator's, supplied through
ingress.annotations. It is therefore invisible to anyone reading this
repository alone, and it is the single annotation this installation depends on.
In this mode it is gone. The Gateway API has no body-size counterpart: it is
implementation policy, not routing, and decision 2 of #114 says such a limit is
DOCUMENTED rather than rebuilt. So the Gateway branch of NOTES.txt names it in
this chart's own terms - an attachment above the Gateway's default is rejected
before Bulwark sees a byte, with a 413 from the proxy that reads like an
application fault, on the one application whose whole purpose is sending files
to other people.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Bulwark it also breaks the login: the OIDC redirect URI registered with the identity provider names ONE origin, and a session that starts on one data path and returns on the other fails on the state cookie. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain) with that second origin registered as a redirect URI too, never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart.
AND THIS READ CHANGES THE CHART'S OWN ACCOUNT OF ITSELF. Before this mode
existed, bulwark.ingress.yaml and the bulwark.publicUrl helper both stated that
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain - true
then, false now, because this template reads it. Both have been corrected
rather than left standing (the same correction garage and calibre-web-automated
needed in groups 1 and 2). The required() below is what makes it true again:
ingress.domain is mandatory in Gateway mode too, unless hostnames are given
explicitly.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix`, and "/" here is
as permanent as it is there - see the header. The port is the Service's
port NUMBER and not its name "http": backendRefs takes a PortNumber, so
unlike the Ingress this reference cannot be made by name. It comes from
the same settings helper the Service renders its port from, so
bulwark.port and this file still cannot drift apart;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.port | int64 }}
{{- end }}
+14 -4
View File
@@ -14,12 +14,22 @@ Prefix, deliberately and permanently.
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"), GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"),
AND FOR THIS CHART THE GATE FREES BOTH VALUES. Checked rather than assumed: AND FOR THIS CHART THE GATE FREES BOTH VALUES. Checked rather than assumed:
nothing outside this template FUNCTIONALLY reads ingress.domain. Bulwark derives Bulwark derives its own public URL from the request origin and takes no
its own public URL from the request origin and takes no base-URL environment base-URL environment variable - there is no KC_HOSTNAME equivalent here - so a
variable - there is no KC_HOSTNAME equivalent here - so a release with release with no routing object at all is complete without a domain. That is the
ingress.enabled: false is complete without a domain. That is the
calibre-web-automated case rather than the matrix-synapse one. calibre-web-automated case rather than the matrix-synapse one.
CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this paragraph used to say
that nothing outside this template FUNCTIONALLY reads ingress.domain.
bulwark.httproute.yaml now does - as the route's hostname, with a required() of
its own - so the value is mandatory in Gateway mode too unless
httpRoute.hostnames is given explicitly. "The gate frees both values" means
BOTH gates off, not this one alone.
THE TWO MODES ARE MUTUALLY EXCLUSIVE and the HTTPRoute template aborts the
render when ingress.enabled and httpRoute.enabled are both true, naming both
value paths.
The one other READER is NOTES.txt, which prints the access URL and the OIDC The one other READER is NOTES.txt, which prints the access URL and the OIDC
redirect URI. That read is deliberately NOT guarded with a required(): `helm redirect URI. That read is deliberately NOT guarded with a required(): `helm
template` does not render NOTES.txt, so a guard there would fire for the first template` does not render NOTES.txt, so a guard there would fire for the first
+26 -3
View File
@@ -166,27 +166,50 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Gates the whole Ingress template (issue #93). Checked in both directions for this chart: nothing outside that template FUNCTIONALLY reads ingress.domain - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so switching the Ingress off frees domain as well as clusterIssuer. The only other reader is NOTES.txt, which renders a visible placeholder rather than carrying a required() that helm template could never reach.", "description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart, and CORRECTED with Gitea #114: it used to say that nothing outside that template functionally reads ingress.domain, which the HTTPRoute now does. Switching the INGRESS off frees clusterIssuer; it frees domain only if httpRoute is off as well. What still holds is the reason - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so a release with no routing object at all is complete without a domain. NOTES.txt renders a visible placeholder rather than carrying a required() that helm template could never reach.",
"type": "boolean" "type": "boolean"
}, },
"clusterIssuer": { "clusterIssuer": {
"type": ["string", "null"] "type": ["string", "null"]
}, },
"domain": { "domain": {
"description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root.", "description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root - which is also why the HTTPRoute renders no URLRewrite filter. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by NOTES.txt for the OIDC redirect URI.",
"type": ["string", "null"] "type": ["string", "null"]
}, },
"className": { "className": {
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault.", "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault - and that is what this installation's live Ingress actually carries (25m, measured 2026-10-12). INERT IN GATEWAY MODE: the Gateway API has no annotation channel and no body-size counterpart at all.",
"type": "object" "type": "object"
} }
} }
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. ONE rule, at the root, with no URLRewrite filter - Bulwark cannot be served under a sub-path, so there is nothing to strip and the #118 prefix/redirect assurance has no precondition to meet here. NOT COVERED IN THIS MODE: proxy-body-size, which this installation's live Ingress carries at 25m and which has no counterpart in the specification - an oversized attachment is then rejected by the Gateway with a 413 that reads like a webmail fault. See values.yaml, httpRoute block. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Setting it means a second origin, which has to be registered as an OIDC redirect URI with the identity provider before a login can succeed on it.",
"type": "array",
"items": { "type": "string" }
}
}
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": { "port": {
"type": ["integer", "null"], "type": ["integer", "null"],
"minimum": 1, "minimum": 1,
+81
View File
@@ -711,6 +711,11 @@ ingress:
# away would produce a release that renders cleanly and whose operator is told # away would produce a release that renders cleanly and whose operator is told
# to register `https:///auth/callback`. # to register `https:///auth/callback`.
# #
# AND A THIRD TIME SINCE Gitea #114: the HTTPRoute reads it as its hostname
# when httpRoute.hostnames is empty, with a required() of its own. So this
# value is mandatory in GATEWAY mode as well, and only a release with both
# modes off can do without it.
#
# BULWARK IS SERVED AT THE ROOT OF THIS HOST AND CANNOT BE SERVED UNDER A # BULWARK IS SERVED AT THE ROOT OF THIS HOST AND CANNOT BE SERVED UNDER A
# PATH. NEXT_PUBLIC_BASE_PATH is compiled into the bundle (point 4 at the top # PATH. NEXT_PUBLIC_BASE_PATH is compiled into the bundle (point 4 at the top
# of this file), so a sub-path deployment would need a rebuilt image. The # of this file), so a sub-path deployment would need a rebuilt image. The
@@ -734,4 +739,80 @@ ingress:
# (or whatever the mail server accepts) to match stalwart's own limit. The # (or whatever the mail server accepts) to match stalwart's own limit. The
# chart does not set it, because the right number is the mail server's and # chart does not set it, because the right number is the mail server's and
# this chart does not know it. # this chart does not know it.
#
# AND THAT RECOMMENDATION IS NOT HYPOTHETICAL - IT IS WHAT THIS INSTALLATION
# RUNS ON. Measured on 2026-10-12: the live Ingress on
# webmail.bekemeyer-klein.de carries exactly
# nginx.ingress.kubernetes.io/proxy-body-size: 25m, supplied here by the
# operator. In GATEWAY MODE this whole block is INERT - the Gateway API has
# no annotation channel for controller behaviour, and body size has no
# counterpart in the specification at all. Read the httpRoute block below
# before switching modes.
annotations: {} annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY an
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what keeps the Gateway implementation replaceable without touching this
# chart again.
#
# ONE ROUTE, ONE RULE, AT THE ROOT. "/" is as permanent here as it is on the
# Ingress, for the same reason: NEXT_PUBLIC_BASE_PATH is compiled into the
# bundle, so Bulwark cannot be served under a sub-path and there is nothing for
# a URLRewrite filter to strip. This chart renders no such filter and needs
# none - which also means the #118 prefix/redirect assurance in
# ci/run-httproute-match-test.sh has no precondition to meet here.
#
# WHAT THIS MODE COSTS, AND IT IS THE ONE ANNOTATION THIS INSTALLATION USES.
# proxy-body-size: 25m is set on the live Ingress by the operator (see the
# annotations block above). It is GONE in this mode and cannot be rebuilt here:
# the Gateway API knows no body size, that is implementation policy, and
# decision 2 of #114 says the limit is documented rather than reconstructed
# with a policy object that would bind this chart to one implementation.
#
# What that looks like in practice, for THIS application: an attachment above
# the GATEWAY's default is rejected before Bulwark sees a byte, and the 413
# comes from the proxy. The user is composing mail with a file attached, so the
# failure arrives at the least recoverable moment and reads like a webmail
# fault. Set the equivalent next to the Gateway - in that implementation's own
# object - BEFORE pointing DNS at it, and size it to what the MAIL SERVER
# accepts (stalwart), not to what feels generous. NOTES.txt prints this in
# Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# AND IF YOU SET IT, REGISTER THAT ORIGIN WITH THE IDENTITY PROVIDER FIRST.
# NOTES.txt derives the OIDC redirect URI from the ACTIVE mode's hostname, so
# a second hostname means a second redirect URI - a login that returns to an
# unregistered origin is rejected at the provider, which says nothing about
# this chart.
hostnames: []
+54
View File
@@ -0,0 +1,54 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/bulwark/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture installs
# the StatefulSet with its PVC and both 1Password items. Trading its Ingress
# for a CRD schema check that cyberchef's fixture already performs against the
# real Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
# route is held here, at render level, which is where its silent failure modes
# live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# routing switch and defaults to TRUE, so without this line the chart aborts
# its own render with both modes on.
#
# ONE ROUTING OBJECT, WHICH IS ALL THIS CHART HAS. #114's measured table and
# the header of ci/run-httproute-match-test.sh both expected two Ingresses and
# a rewrite-target here; neither is the case, and the reason is in
# bulwark.httproute.yaml. In short: Bulwark cannot be served under a sub-path
# (NEXT_PUBLIC_BASE_PATH is compiled into the bundle), so there is one object,
# one rule, at "/", and no URLRewrite filter for the #118 prefix/redirect
# assurance to pair up with. Every "rewrite-target" string in this chart is an
# explanation of why it does not work, and a grep counted those as
# occurrences.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture, so the route renders with its
# hostname from that single source of truth - httpRoute.hostnames stays
# empty on purpose, because the fallback is the path every installation
# will actually use.
# - the route's backendRef is checked against the serving Service of the same
# render, by PORT NUMBER (bulwark.port) rather than by the name "http" the
# Ingress uses, because backendRefs takes a PortNumber. This overlay is
# what holds that number against the Service's.
#
# NOT covered here, and it cannot be: that the hostname answers. No Gateway
# controller runs in CI and no route reaches "Accepted: True". Nor the thing
# this chart actually loses in this mode - the operator's
# proxy-body-size: 25m, which has no counterpart in the specification. That
# loss is documented in values.yaml and printed by NOTES.txt, and documentation
# is not something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+79
View File
@@ -0,0 +1,79 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/homeassistant/test-values.yaml, and here
# that is more than the usual reason. This chart's ingress.enabled DEFAULT IS
# false and the install-test fixture does not switch it on, so the install
# exercises no routing object at all - as with garage. There is nothing to
# trade away: this overlay ADDS coverage that the install-test never had,
# rather than swapping one object for another.
#
# ingress.enabled: false is restated anyway. It is already the default, but the
# two modes are mutually exclusive and the chart aborts its own render when
# both are true, so writing it down here keeps this overlay correct if that
# default ever changes.
#
# AND THIS OVERLAY HAS TO SUPPLY HOSTS, which no other overlay in the sweep
# does. This chart is the DOCUMENTED INGRESS EXCEPTION: it has no
# ingress.domain, so there is no httpRoute.hostnames either - the hostnames and
# the paths come from ingress.hosts, and the chart renders ONE HTTPRoute PER
# ENTRY there. The chart's default entry is the placeholder
# "chart-example.local" with pathType ImplementationSpecific, which WOULD
# render (the chart maps ImplementationSpecific to PathPrefix on "/"), but it
# would exercise exactly one route with one rule and would leave the
# interesting half of this chart's flexible style untested.
#
# So the list below is deliberate, and each entry earns its place:
#
# 1. ha.ci.local with "/" and pathType Prefix - the ordinary case, and the
# clean Prefix -> PathPrefix mapping.
# 2. ha-alt.ci.local with TWO paths, one Prefix and one Exact - this is the
# part that cannot be expressed in a single HTTPRoute together with entry
# 1, because an HTTPRoute's rules apply to EVERY hostname of the object.
# It is therefore the test that the chart really renders one object per
# host entry rather than one object with several hostnames, and it
# exercises the Exact mapping and the multi-rule path at the same time.
#
# Both entries' rules are checked against the Service of the same render. The
# reference is by PORT NUMBER (service.port) rather than by the name "http" the
# Ingress uses, because backendRefs takes a PortNumber - so this overlay is
# what holds that number against the Service's.
#
# NOT covered here, and it cannot be:
#
# - that either hostname answers. No Gateway controller runs in CI and no
# route reaches "Accepted: True".
# - the pathType ImplementationSpecific ABORT on a non-root path. That is a
# render-failure path, and this test only runs renders it expects to
# succeed; it was exercised by hand instead (see the PR).
# - that Home Assistant accepts the Host header or reads X-Forwarded-For.
# Both live in the application's own configuration, which arrives from
# 1Password, and changing the data path changes the proxy address the
# trusted_proxies list has to name. NOTES.txt says so; nothing can assert
# it here.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
ingress:
enabled: false
hosts:
- host: ha.ci.local
paths:
- path: /
pathType: Prefix
- host: ha-alt.ci.local
paths:
- path: /
pathType: Prefix
- path: /api/websocket
pathType: Exact
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+52
View File
@@ -0,0 +1,52 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/keycloak/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture is the
# expensive and informative one - two replicas against a throwaway PostgreSQL,
# two init containers, `kc.sh build` under readOnlyRootFilesystem, and the
# Infinispan cluster forming over jdbc-ping (see that file for the four things
# only a real install can prove). Trading all of it for a CRD schema check that
# cyberchef's fixture already performs against the real Gateway-API CRDs would
# be a bad swap. The install keeps the two Ingresses; the two routes are held
# here, at render level, which is where their silent failure modes live.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, so an
# overlay that only switched httpRoute on would turn this test red.
#
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS:
#
# - ingress.domain comes from the fixture (keycloak.ci.example.com), so the
# PUBLIC route renders with its hostname from that single source of truth
# (httpRoute.hostnames stays empty on purpose - the fallback is the path
# that every installation will actually use).
# - ingress.admin.enabled is true in the fixture with
# ingress.admin.domain set, so the ADMIN route renders too, and its
# parentRefs fall back to httpRoute.parentRefs below - the empty-admin
# branch, which is the one an installation hits unless it wants an
# internal Gateway.
# - both routes' backendRef is checked against the serving Service of the
# same render. Both point at the SAME Service, by PORT NUMBER
# (keycloak.ports.http) rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber - so this overlay is what holds
# that number against the Service's.
#
# NOT covered here, and it cannot be: that either hostname answers. No Gateway
# controller runs in CI, no route reaches "Accepted: True", and the
# proxy-buffer-size/proxy-buffers-number annotations this chart's live
# installation relies on have no counterpart in this mode at all - that loss is
# documented in values.yaml and printed by NOTES.txt, and documentation is not
# something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+59
View File
@@ -0,0 +1,59 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/matrix-synapse/test-values.yaml: the
# install-test runs exactly ONE configuration per chart, and this chart's
# fixture installs the StatefulSet with its PVC and the delegation Deployment.
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
# performs against the real Gateway-API CRDs would be a bad swap. The install
# keeps the Ingress; the routes are held here, at render level, which is where
# their silent failure modes live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# routing switch and defaults to TRUE, so without this line the chart aborts
# its own render with both modes on.
#
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS - and this chart is
# the one in group 3 where "both" means two different hostnames, two different
# paths and two different backends:
#
# - ingress.domain comes from the fixture (matrix.ci.local), so the
# HOMESERVER route renders with its hostname from that single source of
# truth. httpRoute.hostnames stays empty on purpose: the fallback is the
# path every installation will actually use.
# - matrix.delegation.enabled is TRUE in the fixture with
# matrix.delegation.serverUrl: ci.local, so the DELEGATION route renders
# too - on its own hostname, matching PathPrefix /.well-known/matrix, and
# pointing at the delegation Service rather than at Synapse. Its
# parentRefs fall back to httpRoute.parentRefs below, which is the branch
# an installation hits unless the server name sits behind a different
# Gateway.
# - both routes' backendRefs are checked against the Services of the same
# render, and they are DIFFERENT Services. Both references are by PORT
# NUMBER rather than by the name "http" the Ingress uses, because
# backendRefs takes a PortNumber - so this overlay is what holds those
# numbers against the Services'.
#
# No URLRewrite filter is rendered by this chart in either mode: the delegation
# nginx answers on the full /.well-known/matrix/* path, so nothing is stripped
# and the #118 prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and it cannot be: that either hostname answers, which for
# Matrix is also whether FEDERATION works. No Gateway controller runs in CI, no
# route reaches "Accepted: True", and Matrix federation is HTTPS-only while
# nothing in this mode renders a certificate. Nor is the body-size limit
# covered - proxy-body-size: 50m is on the live Ingress and has no counterpart
# in the specification; that loss is documented in values.yaml and printed by
# NOTES.txt, and documentation is not something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+61
View File
@@ -0,0 +1,61 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/stalwart/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture brings up
# the node groups against a throwaway Postgres. Trading its Ingress for a CRD
# schema check that cyberchef's fixture already performs against the real
# Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
# route is held here, at render level, which is where its silent failure modes
# live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# HTTP routing switch and defaults to TRUE, so without this line the chart
# aborts its own render with both modes on.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture, so the route renders with its
# hostname from that single source of truth. httpRoute.hostnames stays
# empty on purpose: the fallback is the path every installation will
# actually use.
# - ingress.nodeGroup comes from the fixture too, so the route is checked to
# point at THAT GROUP'S http Service and no other. This chart is the one in
# group 3 where the backend is group-scoped, and the ONE-route-not-one-
# per-group property is exactly what would be easy to get wrong on a copy.
# - the backendRef is checked against the per-group http Service of the same
# render, by PORT NUMBER (ports.http.containerPort) rather than by the name
# "http" the Ingress uses, because backendRefs takes a PortNumber. Note
# that this chart's NodePort Services publish a DIFFERENT number
# (ports.<name>.nodePort) than the container binds, so naming the wrong one
# here would be a plausible mistake - this overlay is what catches it.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and none of it can be:
#
# - that the hostname answers. No Gateway controller runs in CI and no route
# reaches "Accepted: True".
# - THE CORS CONFIGURATION, which is the thing this chart actually loses in
# this mode and the one that breaks the bulwark webmail. It lives in the
# operator's ingress.annotations, the chart has no values for it, and the
# Gateway API's own CORS filter is Extended-support with observed
# implementation variance - all of which is documented in values.yaml and
# printed by NOTES.txt. Documentation is not something a test can assert.
# - that SMTP/IMAP still work. They never went through the Ingress and do not
# go through the route; the mail Certificate that serves them keeps being
# rendered in this mode, which is this chart's distinguishing feature in
# the sweep, and no render test looks at whether it was left alone.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+64
View File
@@ -0,0 +1,64 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/ytdlp-web-player/test-values.yaml: the
# install-test runs exactly ONE configuration per chart, and this chart's
# fixture is the one that resolves and installs the oauth2-proxy SUBCHART.
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
# performs against the real Gateway-API CRDs would be a bad swap. The install
# keeps the Ingress; the route is held here, at render level.
#
# ingress.enabled: false is not optional here - ingress.enabled defaults to
# TRUE in this chart, so without this line the chart aborts its own render with
# both modes on.
#
# THE SUBCHART IS LEFT DISABLED, as the fixture has it, and that is deliberate
# rather than lazy. The oauth2-proxy chart HAS NO GATEWAY-API SUPPORT - it
# renders an Ingress or nothing - so switching it on here would add a second
# object this test does not look at, while the thing worth exercising in
# Gateway mode is this chart's own route. The interesting interaction between
# the two (one hostname claimed by the proxy's Ingress AND by this chart's
# HTTPRoute, which the ingress-nginx admission webhook cannot catch because the
# kinds differ) is a RENDER-FAILURE path: the chart refuses it. This test only
# runs renders it expects to succeed, so that guard was exercised by hand
# instead - see the PR.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture (ytdlp-web-player.example.com), so
# the route renders with its hostname from that single source of truth.
# httpRoute.hostnames stays empty on purpose: the fallback is the path an
# installation will actually use.
# - the backendRef is checked against the application Service of the same
# render, by PORT NUMBER rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber. That number also has to agree
# with ytdlp-web-player.appServiceAddress, which is what the proxy gets as
# its upstream - three places, and this overlay holds two of them together.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and it cannot be:
#
# - that the hostname answers. No Gateway controller runs in CI and no route
# reaches "Accepted: True".
# - that authentication is in front of the application. With the proxy off
# this route IS the unauthenticated path, which is correct for a fixture
# and is exactly what NOTES.txt warns about for a real installation.
# - anything about how this release is published in the real cluster. There
# is no live Ingress of this chart to compare against: measured 2026-10-12,
# the release runs in namespace jlab-player with both Deployments Ready and
# not one of the cluster's 23 Ingress objects is in that namespace. What
# publishes it could not be determined from the workspace.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+12 -3
View File
@@ -43,9 +43,18 @@
# property of one route - a different route is a different hostname and # property of one route - a different route is a different hostname and
# says nothing about this one. Exact outranks every prefix match in that # says nothing about this one. Exact outranks every prefix match in that
# precedence, so the pairing is about existence and not about order. # precedence, so the pairing is about existence and not about order.
# freesailarr is the first chart this applies to; bulwark is the second # freesailarr is the FIRST AND, AS OF GROUP 3 OF #114, THE ONLY chart this
# rewrite-target chart and meets the same check when group 3 of #114 # applies to. The line that used to stand here said bulwark was the second
# lands, which is the point of checking it here rather than in one chart. # rewrite-target chart and would meet the same check when group 3 landed.
# Group 3 landed and it does not: bulwark has ONE Ingress object, no
# rewrite-target annotation, and cannot have one - NEXT_PUBLIC_BASE_PATH is
# compiled into its bundle, so it cannot be served under a sub-path at all
# and its route is one rule at "/" with no URLRewrite filter. Every
# occurrence of the string "rewrite-target" in that chart is an explanation
# of why it does not help, and #114's measured table arrived at "bulwark,
# freesailarr" by grepping for the string rather than for the annotation.
# The check stays over all charts for the reason below - the mistake
# arrives by copy - not because a second chart is known to need it.
# #
# And the install-test cannot close either gap, even with the real CRDs in the # And the install-test cannot close either gap, even with the real CRDs in the
# cluster: schema validation accepts an empty parentRefs list and accepts a # cluster: schema validation accepts an empty parentRefs list and accepts a
+1 -1
View File
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
icon: https://www.home-assistant.io/images/home-assistant-logo.svg icon: https://www.home-assistant.io/images/home-assistant-logo.svg
type: application type: application
version: 6.1.1+up2026.8.3 version: 6.2.0+up2026.8.3
appVersion: "2026.8.3" appVersion: "2026.8.3"
+102 -19
View File
@@ -18,7 +18,20 @@ print as "1e+06" here just as it did in the rendered manifest.
{{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}} {{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}}
{{- $caps := (index $ctr "capabilities") | default dict -}} {{- $caps := (index $ctr "capabilities") | default dict -}}
{{- $netRaw := has "NET_RAW" ((index $caps "add") | default list) -}} {{- $netRaw := has "NET_RAW" ((index $caps "add") | default list) -}}
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $ing := .Values.ingress -}} {{- $ing := .Values.ingress -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
both read ingress.hosts (Gitea #114).
*/ -}}
{{- $published := or $ing.enabled $httpRoute.enabled -}}
{{- $hosts := $ing.hosts | default list -}} {{- $hosts := $ing.hosts | default list -}}
{{- $tls := $ing.tls | default list -}} {{- $tls := $ing.tls | default list -}}
{{- $ann := $ing.annotations | default dict -}} {{- $ann := $ing.annotations | default dict -}}
@@ -33,7 +46,7 @@ Home Assistant {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
{{- if $ing.enabled }} {{- if $ing.enabled }}
ACCESS ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
{{- range $h := $hosts }} {{- range $h := $hosts }}
{{- $scheme := "http" }} {{- $scheme := "http" }}
{{- range $t := $tls }} {{- range $t := $tls }}
@@ -75,11 +88,73 @@ ACCESS
kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }} kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }}
{{- end }} {{- end }}
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $h := $hosts }}
{{- range $p := ($h.paths | default list) }}
https://{{ $h.host }}{{ $p.path }}
{{- end }}
{{- end }}
ONE HTTPRoute PER ENTRY of ingress.hosts, and nothing else of the routing
path — no Gateway, no class, no Certificate, no policy object:
{{- range $i, $h := $hosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $h.host }}
{{- end }}
This chart has no ingress.domain — it is the documented ingress exception and
keeps upstream's flexible host list — so there is no httpRoute.hostnames
either: the hostnames and the paths above come from ingress.hosts, which is
read in BOTH modes. ingress.className, ingress.tls and ingress.annotations
are NOT read in this mode.
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificates for those hostnames, on the Gateway's listeners.
THE URLS ABOVE SAY https BECAUSE THAT IS WHAT A GATEWAY LISTENER
NORMALLY TERMINATES, not because this release arranged it: ingress.tls
is not read in this mode and this chart requests no certificate in
either mode (see the Ingress-mode text — it never did).
WHAT IS GONE IN THIS MODE, generally rather than specifically: the Gateway
API has no annotation channel for controller behaviour, so everything in
ingress.annotations is inert. For THIS chart there is no measured loss to
report — the installation this repository serves does not deploy
homeassistant at all, so unlike its five siblings in this sweep it had no
operator-supplied annotation to lose. That is an absence of evidence about
that installation, not a property of the chart: whatever YOU put in
ingress.annotations disappears here.
AND ONE THING TO CHECK IN HOME ASSISTANT ITSELF, because no Kubernetes object
reports it: Home Assistant refuses requests whose Host header is not in its
own configuration and builds absolute URLs from it, and it needs the proxy's
address in trusted_proxies to read X-Forwarded-For at all. Changing the data
path changes that address — the Gateway's data-plane pods are not the ingress
controller's. See the trusted-proxies section below; it applies to the
Gateway exactly as it did to ingress-nginx, with a different CIDR.
{{- else }} {{- else }}
ACCESS — NO INGRESS ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled is false, so this release has no Ingress. Nothing else in ingress.enabled and httpRoute.enabled are both false, so this release
this chart reads ingress.hosts, so no other setting silently depends on it. publishes nothing. Nothing else in this chart reads ingress.hosts, so no
other setting silently depends on it.
Reach the instance inside the cluster with Reach the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }} kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }}
@@ -89,21 +164,25 @@ ACCESS — NO INGRESS
because a browser that has already talked to another Home Assistant on because a browser that has already talked to another Home Assistant on
localhost:8123 will otherwise mix up the stored tokens. localhost:8123 will otherwise mix up the stored tokens.
{{- end }} {{- end }}
{{- if and $ing.enabled $placeholder }} {{- if and $published $placeholder }}
THE INGRESS IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST THIS RELEASE IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
ingress.enabled is true while ingress.hosts still contains the default entry {{ if $ing.enabled }}ingress.enabled{{ else }}httpRoute.enabled{{ end }} is true while ingress.hosts still contains the default entry
"chart-example.local". That name resolves nowhere, so the Ingress exists, "chart-example.local". That name resolves nowhere, so the {{ if $ing.enabled }}Ingress{{ else }}HTTPRoute{{ end }} exists,
ingress-nginx accepts it, and no request ever arrives. Set ingress.hosts to {{ if $ing.enabled }}ingress-nginx accepts it{{ else }}the Gateway accepts it{{ end }}, and no request ever arrives. Set ingress.hosts to
the name you actually serve — for example example.com — before assuming the the name you actually serve — for example example.com — before assuming the
release is unreachable for some deeper reason. release is unreachable for some deeper reason.
{{ if $httpRoute.enabled }}ingress.hosts is read in Gateway mode too; it is not an "Ingress-only"
value.{{ end }}
{{- end }} {{- end }}
{{ if eq $replicas "0" }} {{ if eq $replicas "0" }}
SCALED TO ZERO SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers {{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers
until you set it back and upgrade again. The PVC, the OnePasswordItem and until you set it back and upgrade again. The PVC, the OnePasswordItem and
the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no
certificate is re-issued.{{ else }} stay in place, so no state is lost.{{ end }} certificate is re-issued.{{ else if $httpRoute.enabled }} and the HTTPRoutes stay in place, so no state is
lost — the routes keep pointing at a Service with no endpoints, and the
Gateway answers with an error of its own rather than with nothing.{{ else }} stay in place, so no state is lost.{{ end }}
Bringing this release back up is not a no-op: the pod re-reads the config Bringing this release back up is not a no-op: the pod re-reads the config
item from the vault as it starts, and it starts on whatever app version the item from the vault as it starts, and it starts on whatever app version the
@@ -155,23 +234,27 @@ THE CONFIGURATION IS A 1PASSWORD ITEM, AND IT IS READ-ONLY IN THE CONTAINER
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }} kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
BEHIND AN INGRESS, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs BEHIND A PROXY, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
Every request reaching Home Assistant through ingress-nginx arrives from the Every request reaching Home Assistant through {{ if $httpRoute.enabled }}the Gateway{{ else }}ingress-nginx{{ end }} arrives from
controller's pod IP, not from the browser. The http-config field is where {{ if $httpRoute.enabled }}its data-plane pod IP{{ else }}the controller's pod IP{{ end }}, not from the browser. The http-config field is
that is dealt with, and both ways of getting it wrong are quiet in their own where that is dealt with, and both ways of getting it wrong are quiet in
way: their own way:
use_x_forwarded_for: true without the proxy's address in trusted_proxies use_x_forwarded_for: true without the proxy's address in trusted_proxies
makes Home Assistant reject the request outright — the browser gets makes Home Assistant reject the request outright — the browser gets
400 Bad Request and the log names the untrusted address it saw. 400 Bad Request and the log names the untrusted address it saw.
Neither setting at all means every client looks like the ingress Neither setting at all means every client looks like {{ if $httpRoute.enabled }}the Gateway{{ else }}the ingress
controller. Nothing fails; you simply get one address in the logbook for controller{{ end }}. Nothing fails; you simply get one address in the logbook for
everyone, and Home Assistant's login-attempt ban then locks out all users everyone, and Home Assistant's login-attempt ban then locks out all users
at once because they share that address. at once because they share that address.
The value belongs to the ingress controller's POD network, not to the nodes The value belongs to the {{ if $httpRoute.enabled }}GATEWAY's{{ else }}ingress controller's{{ end }} POD network, not to the nodes
and not to the clients: and not to the clients{{ if $httpRoute.enabled }} — and switching from an Ingress to a Gateway CHANGES
that network, because a Gateway's data plane normally runs in a namespace of
its own. A trusted_proxies list carried over unchanged from Ingress mode
turns every request into a 400 if the pod CIDR differs, or silently keeps
attributing all clients to one address if it does not{{ end }}:
use_x_forwarded_for: true use_x_forwarded_for: true
trusted_proxies: trusted_proxies:
@@ -0,0 +1,164 @@
{{/*
HTTPRoute - the Gateway-API alternative to homeassistant.ingress.yaml
(Gitea #114), AND THE ONE CHART IN THE SWEEP WHERE THE CONVENTION HAD TO BEND
RATHER THAN THE CHART.
This chart is the DOCUMENTED INGRESS EXCEPTION (README): it keeps upstream's
flexible ingress values - a LIST of hosts, each with its own LIST of paths,
plus a separate tls list - instead of the repository's required
domain/clusterIssuer pair. The exception stays. Nothing here unifies it, and
the Gateway support is built on top of it rather than next to it.
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames KEY
That is the freesailarr precedent from group 2, and the reason is the same: a
chart-global hostname list could not say which host entry - and therefore which
set of paths - it belonged to. So the hostnames come from the values that
already hold them, ingress.hosts[].host, and httpRoute carries only `enabled`
and `parentRefs`. One HTTPRoute PER HOST ENTRY, with one rule per path of that
entry, because an HTTPRoute's rules apply to every hostname of the object and
this chart's whole point is that the paths may differ per host.
ingress.hosts IS READ IN BOTH MODES, then, and it is not an "Ingress" value any
more than ingress.domain is in the other charts - it is where this release's
public names live. ingress.tls and ingress.className are NOT read here: the
certificate and the class sit on the Gateway and its listeners and belong to
the platform (decision 1 of #114). ingress.annotations is inert - the Gateway
API has no annotation channel.
Nothing was measured on the live cluster for this chart, because there is
nothing to measure: homeassistant is NOT DEPLOYED in jk-cluster (checked
2026-10-12 - 23 Ingress objects cluster-wide, none of them this chart's, and no
workload of it in any namespace). So unlike its five siblings in this group it
carries no operator-supplied annotation that this mode would drop. That is an
absence of evidence about THIS installation, not a demonstration that the chart
needs none: its ingress.annotations default is {} and the chart sets nothing
itself, so whatever a future installation puts there would be dropped in this
mode exactly as elsewhere.
pathType HAS NO CLEAN COUNTERPART, AND THE CHART'S DEFAULT IS THE AWKWARD ONE
Ingress pathType has three values; the Gateway API's path match types are
Exact, PathPrefix and RegularExpression. The mapping:
Exact -> Exact
Prefix -> PathPrefix
ImplementationSpecific -> NOTHING. It means "ask the controller", so there
is no value that preserves it.
And ImplementationSpecific is this chart's DEFAULT (values.yaml,
ingress.hosts[0].paths[0].pathType). Two rules therefore, and the split is not
tidiness:
- On the path "/", ImplementationSpecific is rendered as PathPrefix. Every
controller treats a root ImplementationSpecific path as a prefix match, so
the two coincide and the chart's default works in this mode out of the box.
- On ANY OTHER path, ImplementationSpecific ABORTS THE RENDER. In
ingress-nginx - the class this chart defaults to - an
ImplementationSpecific non-root path is a REGEX, and PathPrefix is not a
regex. Mapping it silently would change matching behaviour while the render
stayed green, which is precisely the class of defect #118 was: a chart that
looks translated and routes differently. So the operator is asked to say
which of Exact or Prefix was meant.
RegularExpression is deliberately NOT offered as an escape hatch: its support
level is Implementation-specific, which would bind this chart to one Gateway
implementation and is what decision 1 of #114 forbids.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoutes would publish the same Service under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Home Assistant there is a second edge: it refuses requests whose Host header is not in its own trusted/allowed list and builds absolute URLs from it, so a client arriving over the other path can be rejected by the application rather than by the proxy. A migration runs over a DIFFERENT hostname - add a second entry to ingress.hosts and switch the modes once it answers - never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- $hosts := .Values.ingress.hosts | default list -}}
{{- if not $hosts -}}
{{- fail "httpRoute.enabled is true but ingress.hosts is empty - there is no hostname to publish and the release would render no route at all, silently. This chart has no ingress.domain (it is the documented ingress exception and keeps upstream's flexible host list), so ingress.hosts is where its public names live and it is read in BOTH modes. Set at least one entry, e.g. ingress.hosts: [{host: ha.example.com, paths: [{path: /, pathType: Prefix}]}]." -}}
{{- end -}}
{{- $first := true -}}
{{- range $i, $entry := $hosts }}
{{- $host := required "Every entry of ingress.hosts needs a host - an HTTPRoute with an empty hostname matches every request that reaches its listener, which on a shared Gateway means this release answers for hostnames belonging to other releases" $entry.host -}}
{{- $paths := $entry.paths | default list -}}
{{- if not $paths -}}
{{- fail (printf "ingress.hosts[%d] (%s) has no paths - the HTTPRoute would carry no rules, and a route with no rules matches nothing while being perfectly valid and Accepted. Give it at least one, e.g. paths: [{path: /, pathType: Prefix}]" $i $host) -}}
{{- end -}}
{{- if not $first }}
---
{{- end }}
{{- $first = false -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
{{- /*
ONE ROUTE PER HOST ENTRY, so the name has to carry the entry's identity. The
index and not the hostname: a hostname is not a legal object-name fragment
(dots are legal in a name but a 63-character label limit and the leading
"<release>-<chart>-" prefix are not something a hostname can be trusted to
fit), and a sanitised hostname would collide for two hosts differing only in
a character that sanitising removes. The index is stable for a given values
file, and REORDERING ingress.hosts THEREFORE RENAMES ROUTES - which deletes
and recreates them on the next upgrade rather than editing them. Harmless
here (a route carries no state and no certificate), and named so it is not a
surprise.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $host | quote }}
rules:
{{- range $paths }}
{{- /*
pathType mapping, with the rule from the header: ImplementationSpecific -
this chart's default - becomes PathPrefix on "/" only, and aborts the
render on any other path rather than silently changing how it matches. An
absent pathType is treated the same way; note that pathType is a REQUIRED
field of networking.k8s.io/v1 Ingress, so an entry without it renders an
Ingress the API server rejects - a pre-existing gap of the Ingress
template, not something this one introduces.
*/}}
{{- $given := .pathType | default "ImplementationSpecific" -}}
{{- $path := required "Every entry of ingress.hosts[].paths needs a path" .path -}}
{{- $type := "" -}}
{{- if eq $given "Exact" -}}
{{- $type = "Exact" -}}
{{- else if eq $given "Prefix" -}}
{{- $type = "PathPrefix" -}}
{{- else if eq $given "ImplementationSpecific" -}}
{{- if eq $path "/" -}}
{{- $type = "PathPrefix" -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with pathType ImplementationSpecific, which has NO counterpart in the Gateway API - it means \"ask the controller\", and the Gateway API offers only Exact, PathPrefix and RegularExpression. On \"/\" the chart maps it to PathPrefix because every controller agrees there, but on a sub-path ImplementationSpecific is a REGEX in ingress-nginx (this chart's default class) and PathPrefix is not one, so mapping it would change how the path matches while the render stayed green. Say which you mean: pathType: Prefix or pathType: Exact. RegularExpression is deliberately not offered - its support level is Implementation-specific and would bind this chart to one Gateway implementation (decision 1 of Gitea #114)" $i $host $path) -}}
{{- end -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with an unknown pathType %q - Ingress knows Exact, Prefix and ImplementationSpecific" $i $host $path $given) -}}
{{- end }}
- matches:
- path:
type: {{ $type }}
value: {{ $path | quote }}
backendRefs:
{{- /*
The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. It comes from service.port, the same value
the Service renders, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they
do.
*/}}
- name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-service"
port: {{ $.Values.service.port | int64 }}
{{- end }}
{{- end }}
{{- end }}
+23 -2
View File
@@ -31,7 +31,7 @@
} }
}, },
"ingress": { "ingress": {
"description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open.", "description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open. Mutually exclusive with httpRoute.enabled (Gitea #114) - the render aborts when both are true.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -39,20 +39,36 @@
"type": "boolean" "type": "boolean"
}, },
"className": { "className": {
"description": "Set to \"\" to omit ingressClassName from the rendered Ingress.", "description": "Set to \"\" to omit ingressClassName from the rendered Ingress. Not read in Gateway mode: the class is a property of the Gateway there, and the Gateway belongs to the platform.",
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Not read in Gateway mode: the Gateway API has no annotation channel for controller behaviour, so anything set here is gone in that mode. The chart sets nothing itself.",
"type": "object" "type": "object"
}, },
"hosts": { "hosts": {
"description": "The public names of this release, READ IN BOTH MODES - this chart has no ingress.domain, so there is no httpRoute.hostnames either and the hostnames live here. In Gateway mode the chart renders one HTTPRoute per entry, with one rule per path of that entry. pathType maps Exact to Exact and Prefix to PathPrefix; ImplementationSpecific (the default below) has no Gateway-API counterpart and is rendered as PathPrefix on \"/\" only - on any other path the render aborts rather than silently changing how the path matches, because in ingress-nginx an ImplementationSpecific sub-path is a regex. RegularExpression is not offered: its support level is Implementation-specific.",
"type": ["array", "null"] "type": ["array", "null"]
}, },
"tls": { "tls": {
"description": "Not read in Gateway mode: the certificate sits on the Gateway's listener and belongs to the platform.",
"type": ["array", "null"] "type": ["array", "null"]
} }
} }
}, },
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO KEYS ONLY, and the missing hostnames key is deliberate: this chart is the documented ingress exception and has no ingress.domain, so the hostnames and paths come from ingress.hosts and the chart renders ONE route per entry there (the freesailarr shape from group 2 of the sweep). Reordering ingress.hosts renames routes, which recreates rather than edits them - harmless, since a route holds no state. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) every route of this release attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged. The same list is used for every route; the hosts differ, the Gateway does not.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
},
"securityContext": { "securityContext": {
"$ref": "#/definitions/securityContext" "$ref": "#/definitions/securityContext"
}, },
@@ -125,6 +141,11 @@
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"resources": { "resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.", "description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
"type": ["object", "null"], "type": ["object", "null"],
+84
View File
@@ -17,22 +17,106 @@ service:
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress: ingress:
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
# true, and the message names both value paths.
enabled: false enabled: false
# Ingress class; set to "" to omit ingressClassName from the rendered Ingress. # Ingress class; set to "" to omit ingressClassName from the rendered Ingress.
# NOT READ IN GATEWAY MODE: the class is a property of the Gateway there, and
# the Gateway belongs to the platform.
className: nginx className: nginx
# NOT READ IN GATEWAY MODE either, and this is the one to be aware of: the
# Gateway API has no annotation channel for controller behaviour, so
# everything put here is simply gone in that mode. This chart sets nothing
# itself, and the installation this repository serves does not deploy
# homeassistant at all (checked 2026-10-12), so there is no operator-supplied
# annotation here today that the switch would drop - unlike its siblings in
# the same sweep. That is an absence of evidence about this installation, not
# a guarantee about yours: whatever you put here disappears in Gateway mode.
annotations: {} annotations: {}
# kubernetes.io/ingress.class: nginx # kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true" # kubernetes.io/tls-acme: "true"
# THE PUBLIC NAMES OF THIS RELEASE, AND READ IN BOTH MODES.
#
# This chart is the documented ingress exception (README): it keeps upstream's
# flexible host list rather than the repository's required
# domain/clusterIssuer pair, and that exception stays. The consequence for
# Gitea #114 is that httpRoute has NO hostnames key - a chart-global hostname
# list could not say which host entry, and therefore which set of paths, it
# belonged to. So this list is where the hostnames live in BOTH modes, and in
# Gateway mode the chart renders ONE HTTPRoute PER ENTRY, with one rule per
# path of that entry.
#
# pathType IN GATEWAY MODE, because the default below is the awkward value.
# Ingress has Exact, Prefix and ImplementationSpecific; the Gateway API has
# Exact, PathPrefix and RegularExpression. Exact and Prefix map cleanly.
# ImplementationSpecific means "ask the controller" and has NO counterpart,
# so:
# - on the path "/" it is rendered as PathPrefix (every controller treats a
# root ImplementationSpecific path as a prefix match, so the default
# below works in Gateway mode unchanged);
# - on ANY OTHER path the render ABORTS and asks you to say which you mean.
# In ingress-nginx an ImplementationSpecific sub-path is a REGEX and
# PathPrefix is not one, so mapping it silently would change how the path
# matches while the render stayed green.
# RegularExpression is deliberately not offered: its support level is
# Implementation-specific and would bind this chart to one Gateway
# implementation.
hosts: hosts:
- host: chart-example.local - host: chart-example.local
paths: paths:
- path: / - path: /
pathType: ImplementationSpecific pathType: ImplementationSpecific
# NOT READ IN GATEWAY MODE: the certificate sits on the Gateway's listener.
tls: [] tls: []
# - secretName: chart-example-tls # - secretName: chart-example-tls
# hosts: # hosts:
# - chart-example.local # - chart-example.local
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true. The chart renders ONLY HTTPRoutes with parentRefs: no Gateway, no
# class, no Certificate, and no implementation-specific policy object. Class
# and certificate sit on the Gateway and its listeners and belong to the
# platform (cluster-config), which is what keeps the Gateway implementation
# replaceable without touching this chart again.
#
# TWO KEYS ONLY, and the missing third one is deliberate: there is no
# httpRoute.hostnames here, because this chart has no ingress.domain either.
# The hostnames and the paths come from ingress.hosts above - see the comment
# there, including the pathType mapping. This is the freesailarr shape from
# group 2 of the sweep, for the same reason.
#
# ONE HTTPRoute PER ENTRY of ingress.hosts, named
# <release>-homeassistant-httproute for the first and ...-httproute-<index> for
# the rest. REORDERING ingress.hosts RENAMES ROUTES and so deletes and
# recreates them on the next upgrade instead of editing them - harmless,
# because a route holds no state and no certificate, but worth knowing before
# it looks like a bug.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) EVERY route of this release attaches to - a platform object,
# usually in another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so. The same list is used for every route
# this chart renders; the hosts differ, the Gateway does not.
parentRefs: []
# Documented deviation from the repo-wide hardening standard: the official # Documented deviation from the repo-wide hardening standard: the official
# Home Assistant container must start as root — it boots via the s6-overlay # Home Assistant container must start as root — it boots via the s6-overlay
# init system, which performs its setup as uid 0 (the image supports no # init system, which performs its setup as uid 0 (the image supports no
+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# procedure lives in NOTES.txt and on the wiki page instead of in a # 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 # packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
# merging an app-version PR that changes the minor. # merging an app-version PR that changes the minor.
version: 1.1.0+up26.8.0 version: 1.2.0+up26.8.0
appVersion: "26.8.0" appVersion: "26.8.0"
maintainers: maintainers:
+111 -8
View File
@@ -1,6 +1,23 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}} {{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}} {{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}} {{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $httpRouteAdmin := $httpRoute.admin | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $adminHostnames := $httpRouteAdmin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (.Values.ingress.admin | default dict).domain -}}
{{- end -}}
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}} {{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .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 }}. Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
@@ -8,19 +25,97 @@ Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in nam
{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database, {{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database,
external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}. external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}.
ENDPOINTS ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }} {{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }}
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only {{ else if $httpRoute.enabled }}{{ range $hostnames }} Public: https://{{ . }}
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin {{ end }}{{ else }} Public: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is both false) - in-cluster only
{{ end }}{{ if not $adminHostname }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
served on the public host and Keycloak generates its URLs for it served on the public host and Keycloak generates its URLs for it
{{ else if $httpRoute.enabled }}{{ range $adminHostnames }} Admin: https://{{ . }}/admin
{{ end }}{{ else if .Values.ingress.enabled }} Admin: {{ include "keycloak.adminUrl" . }}/admin
{{ else }} Admin: {{ include "keycloak.adminUrl" . }}/admin is what KC_HOSTNAME_ADMIN says, and
NOTHING PUBLISHES IT. ingress.admin.enabled is true, so the console
still generates its URLs for that host, but with both modes off
there is no Ingress and no HTTPRoute to carry it. Use the
port-forward below, or switch a mode on.
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }} {{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }}
Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves
/health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }} /health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
WITHOUT authentication. It has a ClusterIP Service WITHOUT authentication. It has a ClusterIP Service
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only. ({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
{{ 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 -
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 }}
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway(s) named in parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (public)
{{- end }}
{{- if $adminHostname }}
{{- range ($httpRouteAdmin.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (admin)
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificates for those hostnames, on the Gateway's listeners.
ingress.clusterIssuer is NOT used in this mode and is not required: the
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.
AND THE ONE YOU WILL NOT NOTICE UNTIL A LOGIN FAILS: THE PROXY BUFFERS.
Keycloak's responses carry large headers - JWTs, SAML assertions, long
Set-Cookie chains during the login flow. In Ingress mode that is handled by
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
which this chart has never set: they come from ingress.annotations and
ingress.admin.annotations, i.e. from whoever installs it. IN THIS MODE THEY
ARE GONE and there is NO counterpart in the Gateway API - buffer sizing is
implementation policy, not routing, and this chart renders no policy object
on purpose (it would bind the chart to one Gateway implementation).
The failure mode, so you recognise it: the PROXY answers 502 while Keycloak
logs the request as successful. Nothing says a chart value was ignored,
because no chart value was involved. Set the equivalent next to the Gateway,
in your implementation's own object, BEFORE you point DNS at it - and raise
it, do not just match 16k, if you use long-lived tokens or SAML.
ingress.annotations and ingress.admin.annotations are inert here in general:
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.
THE ADMIN HOSTNAME'S "INTERNAL CLASS" MOVES TO THE PARENT. What
ingress.admin.className does in the other mode, httpRoute.admin.parentRefs
does here: point the admin route at an internal Gateway. It falls back to
httpRoute.parentRefs, so leaving it empty puts both hostnames on the same
Gateway.
{{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD, so a missing or The operator materializes one Secret key per item FIELD, so a missing or
misnamed field leaves the pod in CreateContainerConfigError - with no message misnamed field leaves the pod in CreateContainerConfigError - with no message
@@ -61,7 +156,7 @@ FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE
jdbc-ping did not find the peer - check that both pods can reach each jdbc-ping did not find the peer - check that both pods can reach each
other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database. other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database.
3. Log in at {{ if $adminHostname }}{{ include "keycloak.adminUrl" . }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }} 3. Log in at {{ if and $adminHostname (not $httpRoute.enabled) }}{{ include "keycloak.adminUrl" . }}/admin{{ else if $adminHostname }}https://{{ first $adminHostnames }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else if $httpRoute.enabled }}https://{{ first $hostnames }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
with the bootstrap credentials from the vault item above. with the bootstrap credentials from the vault item above.
4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one. 4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one.
@@ -132,7 +227,10 @@ TRAPS WORTH KNOWING BEFORE YOU NEED THEM
on the reverse proxy level." So /admin/realms/... is reachable through the on the reverse proxy level." So /admin/realms/... is reachable through the
public host whatever you put on the admin host, and an IP allow-list on the public host whatever you put on the admin host, and an IP allow-list on the
admin Ingress restricts nothing on its own. This chart renders no deny rule admin Ingress restricts nothing on its own. This chart renders no deny rule
for /admin - see values.yaml for why not. for /admin - see values.yaml for why not.{{ if $httpRoute.enabled }} In GATEWAY MODE there is not
even a place in this chart to put one: no annotation channel, and no
implementation-specific policy object by design. The restriction belongs
next to the Gateway, which is where it can also be tested.{{ end }}
- A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the - A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the
database, so - upstream - "clusters that share the same database but should database, so - upstream - "clusters that share the same database but should
@@ -200,5 +298,10 @@ WHAT THIS CHART DOES NOT RENDER
- No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port - No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the {{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
replicas, or the cluster silently becomes two islands. replicas, or the cluster silently becomes two islands.
- No deny rule for /admin on the public Ingress, and no restriction of the - No deny rule for /admin on the public Ingress{{ if $httpRoute.enabled }} or route{{ end }}, and no restriction of
administration REST API. That is reverse-proxy work - see the first trap. the administration REST API. That is reverse-proxy work - see the first
trap.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
+164
View File
@@ -0,0 +1,164 @@
{{/*
HTTPRoute - the Gateway-API alternative to keycloak.ingress.yaml (Gitea #114).
TWO ROUTES, because this chart has TWO Ingress objects: the public hostname and
the admin hostname. Read keycloak.ingress.yaml first - everything it says about
what the admin hostname is NOT (it is not an access control) holds here
unchanged, and the Gateway API gives no new way to make it one.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object. That subset is identical
on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is the whole
point (decision 1 of #114). Class and certificate therefore belong to the
PLATFORM, on the Gateway and its listeners, in cluster-config.
WHAT ingress.admin.className BECOMES, AND WHY THERE IS AN admin BLOCK HERE
The admin Ingress exists so the console can sit on an INTERNAL ingress class or
in a different DNS zone (ingress.admin.className, falling back to
ingress.className). "Which data path" is a property of the Gateway in this API,
not of the route, so its counterpart is the PARENT: httpRoute.admin.parentRefs,
falling back to httpRoute.parentRefs exactly as the class does. Without that
fallback the admin route could only ever hang off the same Gateway as the public
one, and the one useful half of the second hostname would be gone in this mode.
THE ANNOTATIONS THIS MODE DROPS ARE NOT HYPOTHETICAL FOR THIS CHART
Measured on the live cluster on 2026-10-12, on BOTH of this chart's Ingress
objects (jk-cluster/keycloak):
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
The CHART sets neither - they are operator-supplied, through
ingress.annotations and ingress.admin.annotations, which is why a reader of
this repository alone would never find them. They are also not cosmetic:
Keycloak's responses carry large headers (JWTs, SAML assertions, long Set-Cookie
chains on the login flow), and a proxy whose buffer is too small answers 502
ITSELF. The failure then looks like Keycloak being broken while Keycloak logged
a perfectly successful request.
ingress.annotations and ingress.admin.annotations are INERT in this mode - the
Gateway API has no annotation channel for controller behaviour, and buffer
sizing has no counterpart in the specification at all; it is implementation
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 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.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Keycloak it is worse than for a stateless app, because the server derives KC_HOSTNAME from ingress.domain and generates absolute redirect and asset URLs for it - a login that starts on one path and finishes on the other fails on the state cookie, not with an error that names the cause. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $admin := $httpRoute.admin | default dict -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- /*
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
required in EVERY mode in this chart and its required() lives in the
keycloak.frontendUrl helper, because the Deployment reads it as KC_HOSTNAME -
guideline 12.1, and the reason this chart's value cannot be gated away. The
required() below is therefore a second, redundant guard rather than the only
one; it stays so the message names the route when the route is what is missing
a hostname.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the public HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the serving
Service. The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. It is read from the same settings helper the
Service renders its port from, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- if $adminEnabled }}
---
{{- /*
The admin route. Gated on ingress.admin.enabled, the same switch the admin
Ingress uses, because that value is not an Ingress switch: it also decides
whether KC_HOSTNAME_ADMIN is set on the Deployment, i.e. whether the admin
hostname is in play AT ALL. So it stays mode-independent and the ROUTING
OBJECT follows the mode - which is also why switching to Gateway mode must not
require setting it to false.
*/ -}}
{{- $adminHostnames := $admin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (required "An admin Domain/Host must be provided while ingress.admin.enabled is true: set ingress.admin.domain (used as the admin HTTPRoute's single hostname when httpRoute.admin.hostnames is empty) or set httpRoute.admin.hostnames explicitly" ((.Values.ingress.admin | default dict).domain)) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the public one - the counterpart of
ingress.admin.className falling back to ingress.className. An internal
Gateway here is the useful half of the admin hostname, and still not the
access control keycloak.ingress.yaml is about.
*/}}
parentRefs:
{{- toYaml ($admin.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
{{- toYaml $adminHostnames | nindent 4 }}
rules:
{{- /*
The SAME backend and the same path as the public route. 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.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- end }}
+16 -3
View File
@@ -29,6 +29,21 @@ 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 required even with ingress.enabled: false, and its required() lives in the
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
this file, so switching both Ingresses off frees it. 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" }} {{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- if .Values.ingress.enabled }} {{- if .Values.ingress.enabled }}
@@ -61,10 +76,8 @@ spec:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }} - {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }} {{- end }}
{{- if $adminEnabled }} {{- if and .Values.ingress.enabled $adminEnabled }}
{{- if .Values.ingress.enabled }}
--- ---
{{- end }}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
+41 -1
View File
@@ -187,15 +187,55 @@
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.", "description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open. Inert in Gateway mode.",
"type": "object" "type": "object"
} }
} }
} }
} }
},
"httpRoute": {
"description": "Gateway-API alternative to the TWO Ingresses (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, the public hostname and the admin one; the second is gated on ingress.admin.enabled, because that value also sets KC_HOSTNAME_ADMIN on the Deployment and is therefore not an Ingress switch. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. NOT COVERED IN THIS MODE: the proxy-buffer-size and proxy-buffers-number annotations this installation relies on - the Gateway API has no counterpart for proxy buffer sizing, and Keycloak's large headers make a too-small buffer answer 502 at the proxy while the application logs success. See values.yaml, httpRoute block.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the public route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the public hostname has a single source of truth.",
"type": "array",
"items": { "type": "string" }
},
"admin": {
"description": "The admin route. Rendered while ingress.admin.enabled is true - the same switch the admin Ingress uses, so the admin hostname is available in both modes.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"parentRefs": {
"description": "Empty falls back to httpRoute.parentRefs - the counterpart of ingress.admin.className falling back to ingress.className, because \"which data path\" is a property of the Gateway in this API and not of the route. An internal Gateway here is what an internal ingress class is in the other mode, and still not an access control.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.admin.domain].",
"type": "array",
"items": { "type": "string" }
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": { "port": {
"type": ["integer", "null"], "type": ["integer", "null"],
"minimum": 1, "minimum": 1,
+85
View File
@@ -642,6 +642,11 @@ ingress:
# Extra annotations for the public Ingress; merged with the cert-manager # Extra annotations for the public Ingress; merged with the cert-manager
# annotation the chart always sets. # annotation the chart always sets.
#
# INERT IN GATEWAY MODE (httpRoute.enabled) - the Gateway API has no
# annotation channel for controller behaviour. That matters for THIS chart in
# particular: see the httpRoute block below for the proxy-buffer annotations
# this installation actually relies on and what their loss looks like.
annotations: {} annotations: {}
admin: admin:
@@ -684,4 +689,84 @@ ingress:
# annotation. This is where an IP allow-list would go # annotation. This is where an IP allow-list would go
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat # (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
# above firmly in mind. # above firmly in mind.
#
# INERT IN GATEWAY MODE, like ingress.annotations - see the httpRoute block
# below for what this installation actually has in here today and what
# losing it costs.
annotations: {} annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE TWO INGRESSES (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY
# HTTPRoutes with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what makes the Gateway implementation replaceable without touching this
# chart again.
#
# TWO ROUTES, mirroring the two Ingresses: the public hostname and the admin
# hostname. ingress.admin.enabled keeps gating BOTH forms, because it is not an
# Ingress switch - it decides whether KC_HOSTNAME_ADMIN is set on the
# Deployment, i.e. whether the admin hostname exists at all.
#
# WHAT THIS MODE COSTS THIS CHART, MEASURED AND NOT HYPOTHETICAL. On
# 2026-10-12 both of this chart's live Ingress objects carried
#
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: 4
#
# set by the OPERATOR through the two annotations blocks above - this chart
# sets neither, which is why reading the chart alone does not reveal them. The
# Gateway API has no annotation channel and no counterpart for proxy buffer
# sizing: it is implementation policy, not routing, so there is nothing here to
# render and nothing this chart could promise. The consequence is specific:
# Keycloak's responses carry large headers (JWTs, SAML assertions, long
# Set-Cookie chains during the login flow), and a proxy whose buffer is too
# small answers 502 ITSELF, while Keycloak logs a successful request. The
# symptom therefore points at the application and the cause is at the proxy.
# Set the equivalent next to the Gateway, in that implementation's own object,
# BEFORE moving the host. NOTES.txt prints this in Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the PUBLIC route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the public hostname has one source of
# truth. ingress.domain is required in every mode in this chart anyway (the
# Deployment reads it as KC_HOSTNAME), so setting this list is only for
# publishing under a different or an ADDITIONAL name - which is how a
# migration from the Ingress runs: the HTTPRoute takes a second hostname
# while the Ingress keeps ingress.domain.
hostnames: []
admin:
# The admin route's own parent. null/empty falls back to
# httpRoute.parentRefs - the exact counterpart of ingress.admin.className
# falling back to ingress.className. "Which data path" is a property of the
# Gateway in this API rather than of the route, so an INTERNAL Gateway here
# is what an internal ingress class is in the other mode. Still not the
# access control the ingress.admin paragraph above is about.
parentRefs: []
# Empty means [ingress.admin.domain]. Required (via that value) while
# ingress.admin.enabled is true and httpRoute.enabled is true.
hostnames: []
+1 -1
View File
@@ -3,7 +3,7 @@ name: matrix-synapse
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
type: application type: application
version: 7.1.1+upv1.159.0 version: 7.2.0+upv1.159.0
appVersion: "v1.159.0" appVersion: "v1.159.0"
maintainers: maintainers:
- name: jklein - name: jklein
+145 -14
View File
@@ -1,5 +1,29 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $replicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.matrix.replicas) -}} {{- $replicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.matrix.replicas) -}}
{{- $delegation := .Values.matrix.delegation | default dict -}} {{- $delegation := .Values.matrix.delegation | default dict -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $httpRouteDelegation := $httpRoute.delegation | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
every paragraph below that used to ask ingress.enabled was really asking
whether anything publishes this release. Gitea #114, and the sure.publishedOverTls
/ freesailarr.published shape.
*/ -}}
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $delegationHostnames := $httpRouteDelegation.hostnames | default list -}}
{{- if not $delegationHostnames -}}
{{- $delegationHostnames = list $delegation.serverUrl -}}
{{- end -}}
{{- $delegationReplicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $delegation.replicas) -}} {{- $delegationReplicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $delegation.replicas) -}}
{{- $res := fromYaml (include "matrix-synapse.effectiveResources" .Values.matrix.resources) -}} {{- $res := fromYaml (include "matrix-synapse.effectiveResources" .Values.matrix.resources) -}}
{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} {{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
@@ -14,7 +38,7 @@ Matrix Synapse {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
{{- if .Values.ingress.enabled }} {{- if .Values.ingress.enabled }}
ACCESS ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }} https://{{ .Values.ingress.domain }}
That host carries the whole client and federation API: the Ingress sends "/" That host carries the whole client and federation API: the Ingress sends "/"
@@ -24,11 +48,96 @@ ACCESS
shows a warning and federation fails. Follow it with shows a warning and federation fails. Follow it with
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
That host carries the whole client and federation API: the HTTPRoute sends
"/" on it to the Synapse Service, which forwards port 80 to the container's
8008.
THIS RELEASE RENDERS {{ if $delegation.enabled }}TWO HTTPRoutes{{ else }}ONE HTTPRoute{{ end }} AND NOTHING ELSE of the routing
path — no Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
{{- if $delegation.enabled }}
{{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute {{ join ", " $delegationHostnames }}
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostname does not answer — which for Matrix means federation is down, not
just the web client:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway(s) named in parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (homeserver)
{{- end }}
{{- if $delegation.enabled }}
{{- range ($httpRouteDelegation.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (delegation)
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. {{ if $delegation.enabled }}The TLS certificates — one per hostname, on the Gateway's listeners,
or one wildcard listener covering both{{ else }}The TLS certificate for the hostname, on the Gateway's listener{{ end }}.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret{{ if $delegation.enabled }} and no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation secret{{ end }} and nothing for
cert-manager to do for this release. MATRIX FEDERATION IS HTTPS-ONLY,
so a listener without a valid certificate for these names is a
federation outage that no probe in this release can see.
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
In Ingress mode an operator sets
nginx.ingress.kubernetes.io/proxy-body-size through ingress.annotations —
this installation runs with 50m — because Synapse's media repository takes
uploads and nginx's default of 1m rejects nearly all of them. IN THIS MODE
THAT IS GONE: the Gateway API has no body-size field, it is implementation
policy rather than routing, and this chart renders no policy object on
purpose (it would bind the chart to one Gateway implementation).
The ceiling in force is the smaller of max_upload_size in your
homeserver.yaml — which this chart cannot see, it comes from 1Password —
and whatever the GATEWAY enforces. Neither number anyone can point at is
necessarily the one that decides. Set the Gateway's half, in your
implementation's own object, to match max_upload_size, BEFORE you point DNS
at it.
AND ONE THING NOT TO RE-CREATE. The live Ingress also carries a
configuration-snippet of three more_set_headers directives
(X-Forwarded-For $remote_addr, X-Forwarded-Proto $scheme, Host). It LOOKS
like what Synapse's x_forwarded setting depends on. It is not:
more_set_headers is the headers-more module's OUTPUT-header directive, so
those three headers go on the RESPONSE TO THE CLIENT and Synapse never sees
them. What Synapse reads with x_forwarded: true is the REQUEST's
X-Forwarded-For, which the proxy supplies by itself. Do not spend effort
rebuilding this at the Gateway, and do not read its absence as the cause of
a rate-limiting or federation problem. (This is a reading of the directive's
documented semantics; nothing here A/B-tested a Synapse instance.)
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
{{- else }} {{- else }}
ACCESS — NO INGRESS ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled is false, so this release has no Ingress and no certificate; ingress.enabled and httpRoute.enabled are both false, so this release has no
ingress.clusterIssuer is not needed. Ingress, no HTTPRoute and no certificate; ingress.clusterIssuer is not
needed.
{{- if $delegation.enabled }} {{- if $delegation.enabled }}
ingress.domain still is: the delegation nginx renders it into the ingress.domain still is: the delegation nginx renders it into the
/.well-known/matrix/* documents, so it stays the host every other Matrix /.well-known/matrix/* documents, so it stays the host every other Matrix
@@ -56,10 +165,10 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE
→ {"m.server": "{{ .Values.ingress.domain }}:443"} → {"m.server": "{{ .Values.ingress.domain }}:443"}
/.well-known/matrix/client /.well-known/matrix/client
→ {"m.homeserver": {"base_url": "https://{{ .Values.ingress.domain }}"}} → {"m.homeserver": {"base_url": "https://{{ .Values.ingress.domain }}"}}
{{- if .Values.ingress.enabled }} {{- if $published }}
The Ingress publishes them on a SECOND host. Read that split carefully, {{ if $httpRoute.enabled }}A SECOND HTTPRoute publishes{{ else }}The Ingress publishes{{ end }} them on a SECOND host. Read that split
because the chart cannot check it for you: carefully, because the chart cannot check it for you:
matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) matrix.delegation.serverUrl ({{ $delegation.serverUrl }})
is the DELEGATING host — the domain in every user ID (@user:{{ $delegation.serverUrl }}). is the DELEGATING host — the domain in every user ID (@user:{{ $delegation.serverUrl }}).
@@ -69,18 +178,36 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE
is where Synapse actually answers. It appears inside the documents is where Synapse actually answers. It appears inside the documents
above and needs no relation to the server name. above and needs no relation to the server name.
Only the path /.well-known/matrix is routed to nginx on {{ $delegation.serverUrl }}; Only the path /.well-known/matrix is routed to nginx on {{ join ", " $delegationHostnames }};
everything else on that host is not served by this release. Both hosts need everything else on that host is not served by this release.
{{- if $httpRoute.enabled }}
AND THAT CO-EXISTENCE IS EASIER HERE THAN IT WAS. The server name is usually
an apex domain that already serves a website, and under ingress-nginx two
Ingress objects cannot both claim one hostname — the admission webhook
rejects the second — which is why the delegation rule had to sit inside this
chart's single Ingress object. HTTPRoutes attached to the same listener for
the same hostname are MERGED by the implementation, longest match winning,
so this route may sit beside whatever owns that apex without either side
knowing about the other.
Both hosts need a certificate ON THE GATEWAY'S LISTENERS — this chart renders
none in this mode — and a listener without a valid certificate for the server
name is a federation outage, not a cosmetic problem.
{{- else }}
Both hosts need
a certificate — the second one is tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation — and a certificate — the second one is tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation — and
cert-manager only issues it once {{ $delegation.serverUrl }} resolves to this cluster. cert-manager only issues it once {{ $delegation.serverUrl }} resolves to this cluster.
A pending certificate there is a federation outage, not a cosmetic problem. A pending certificate there is a federation outage, not a cosmetic problem.
{{- end }}
Check what is actually being served, from outside the cluster: Check what is actually being served, from outside the cluster:
curl -s https://{{ $delegation.serverUrl }}/.well-known/matrix/server curl -s https://{{ first $delegationHostnames }}/.well-known/matrix/server
{{- else }} {{- else }}
Nothing publishes them, though: ingress.enabled is false, so the documents Nothing publishes them, though: ingress.enabled and httpRoute.enabled are
both false, so the documents
exist only on the in-cluster Service {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service, and exist only on the in-cluster Service {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service, and
matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is rendered into no object of this matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is rendered into no object of this
release. Whatever terminates TLS in front of the cluster has to route release. Whatever terminates TLS in front of the cluster has to route
@@ -101,7 +228,7 @@ NO DELEGATION IN THIS RELEASE — FEDERATION LIKELY DOES NOT WORK
8448 anywhere: the container publishes 8008 only, and the Service maps port 8448 anywhere: the container publishes 8008 only, and the Service maps port
80 onto it. Unless the delegation is served by something else (a static 80 onto it. Unless the delegation is served by something else (a static
.well-known on the server-name host, or an SRV record), federation stays .well-known on the server-name host, or an SRV record), federation stays
broken{{ if .Values.ingress.enabled }} while clients on {{ .Values.ingress.domain }} work fine{{ end }}. broken{{ if $published }} while clients on {{ first $hostnames }} work fine{{ end }}.
Setting matrix.delegation.enabled to true and matrix.delegation.serverUrl Setting matrix.delegation.enabled to true and matrix.delegation.serverUrl
to the server name lets this chart serve it. to the server name lets this chart serve it.
{{- end }} {{- end }}
@@ -120,7 +247,11 @@ SCALED TO ZERO
{{- end }} {{- end }}
{{- end }} {{- end }}
The PVC, the Secret and the Services stay in place, so no data is lost{{ if .Values.ingress.enabled }} and The PVC, the Secret and the Services stay in place, so no data is lost{{ if .Values.ingress.enabled }} and
the certificates are not re-issued{{ end }}. Set it back and upgrade again to the certificates are not re-issued{{ else if $httpRoute.enabled }} and the
HTTPRoutes stay in place, so the hostnames keep pointing at Services with no
endpoints — the Gateway answers with an error of its own rather than with
nothing, and remote homeservers see a failing .well-known instead of an
unresolvable name{{ end }}. Set it back and upgrade again to
return. return.
{{- end }} {{- end }}
@@ -190,7 +321,7 @@ THE SERVER NAME IS DECIDED ONCE, AND NOT BY matrix.serverName
a different name is a new server with a new database, not an upgrade. Decide a different name is a new server with a new database, not an upgrade. Decide
it before the first start — together with matrix.delegation.serverUrl, which it before the first start — together with matrix.delegation.serverUrl, which
has to match it. has to match it.
{{- if and .Values.ingress.enabled $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }} {{- if and $published $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }}
{{- if ne (printf "%v" .Values.matrix.serverName) (printf "%v" $delegation.serverUrl) }} {{- if ne (printf "%v" .Values.matrix.serverName) (printf "%v" $delegation.serverUrl) }}
MISMATCH IN THIS RELEASE: matrix.serverName is "{{ .Values.matrix.serverName }}" but the MISMATCH IN THIS RELEASE: matrix.serverName is "{{ .Values.matrix.serverName }}" but the
@@ -0,0 +1,198 @@
{{/*
HTTPRoute - the Gateway-API alternative to matrix-synapse.ingress.yaml
(Gitea #114).
TWO ROUTES, AND THE SHAPE IS NOT WHAT #114 EXPECTED. The issue describes this
chart as rendering "its Ingresses in a loop over several apps". It does not -
that is freesailarr. This chart renders ONE Ingress object with TWO host rules:
ingress.domain / synapse service
matrix.delegation.serverUrl /.well-known/matrix delegation service
In the Gateway API that has to become TWO HTTPRoute objects, and not because of
style: spec.hostnames applies to the WHOLE object, so one route can carry
several hostnames only when they want the SAME rules. These two want different
paths and different backends, so one object per hostname is the only faithful
translation.
WHAT THIS MODE IMPROVES, WHICH IS WORTH KNOWING BEFORE THE MIGRATION
The delegation hostname is the Matrix SERVER NAME - the apex domain, not the
homeserver's own host - and it is almost always already serving something else
(a website at /). Under ingress-nginx two Ingress objects on one hostname are a
fight: the admission webhook rejects the second claim on the same host and
path, which is why the delegation rule has to live INSIDE this chart's single
Ingress object rather than beside it. HTTPRoutes do not work that way: several
routes may attach to the same listener for the same hostname and the
implementation merges them by match specificity (a longer path wins). So in
this mode the /.well-known/matrix route can sit next to whatever else owns that
apex, without either side knowing about the other.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object - the subset that looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
listeners, in cluster-config. Note that the two hostnames need certificates on
TWO listeners (or one wildcard listener covering both), which in Ingress mode
were two entries in the one object's tls block.
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND ONE OF THE TWO IS NOT
WHAT IT LOOKS LIKE
On 2026-10-12 the live Ingress (jk-cluster, namespace matrix-synapse) carried,
all of it OPERATOR-supplied through ingress.annotations - this chart sets none
of it:
nginx.ingress.kubernetes.io/proxy-body-size: 50m
nginx.org/client-max-body-size: 50m
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Forwarded-For $remote_addr";
more_set_headers "X-Forwarded-Proto $scheme";
more_set_headers "Host $host:$server_port";
1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads,
its own ceiling is max_upload_size in homeserver.yaml - which this chart
cannot see, because the whole config arrives from 1Password through
secrets.configSecretRef - and the effective limit is the smaller of the two.
In this mode the proxy half becomes THE GATEWAY'S default, so neither number
anyone can point at is necessarily the one that decides. There is no
counterpart in the Gateway API: body size is implementation policy, not
routing, and decision 2 of #114 says it is documented rather than rebuilt.
(nginx.org/client-max-body-size is the same limit spelled for the OTHER
nginx ingress controller, NGINX Inc's; it is a hedge across both
implementations and it disappears for the same reason.)
2. THE SNIPPET IS NOT LOAD-BEARING, AND THAT TOOK CHECKING RATHER THAN
ASSUMING. It looks like the thing Synapse's x_forwarded setting depends on -
client IP for rate limiting, and federation. It is not, because
more_set_headers is the module's OUTPUT-header directive:
headers-more-nginx-module, verbatim - more_set_headers "Replaces (if any) or
adds (if not any) the specified OUTPUT headers", while the request-side
directive is a different one, more_set_input_headers ("it operates on input
headers (or request headers)"). So these three headers are added to the
RESPONSE TO THE CLIENT. Synapse never sees them; what Synapse reads with
x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx
populates itself.
The honest statement for the migration is therefore: not translatable, and
not needed. The Gateway API's ResponseHeaderModifier could set static
response headers but knows no nginx variables ($remote_addr, $scheme,
$host), so a rebuild is impossible anyway - and pointless, since dropping
them changes nothing Synapse depends on. What does change is that a client
stops receiving three headers echoing the proxy's view of its own request,
which is at best noise.
This is a claim about a directive's documented semantics, not an experiment:
nothing here observed a Synapse instance with and without the snippet.
Both are named in the Gateway branch of NOTES.txt, the first as a limit to
re-establish at the Gateway and the second as a thing NOT to re-establish.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Services under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Matrix it is worse than for a stateless app: the /.well-known/matrix delegation tells every OTHER homeserver in the federation where to find this one, and a server name that resolves to two data paths makes federation succeed or fail per remote server and per retry. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and the delegation host moves last, after the homeserver host answers." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- /*
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
read outside the routing templates in this chart - the delegation nginx serves
it inside /.well-known/matrix/client - so it already carries a required() of
its own there and stays mandatory with the Ingress off (README, guideline 12,
point 1). This required() is the route's own, so the message names the route.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the homeserver HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the Synapse
Service. The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. ci/run-httproute-match-test.sh is what holds the
pair - it fails when a backendRef names a Service, or a port, that the
same render does not create.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- if .Values.matrix.delegation.enabled }}
---
{{- /*
The delegation route, on the SERVER NAME rather than on the homeserver's host.
Gated on matrix.delegation.enabled exactly as the second host rule of the
Ingress is, and for the same reason: without the delegation workload there is
nothing to route to.
Its hostname deliberately does NOT fall back to httpRoute.hostnames. The
delegation host is matrix.delegation.serverUrl - the Matrix server name, which
is a different thing from the homeserver's hostname and is what remote servers
resolve. Letting a chart-global hostname list cover it would make the two
indistinguishable, and the migration order (homeserver host first, server name
last) depends on telling them apart. httpRoute.delegation.hostnames exists for
the one case where the server name itself has to move.
*/ -}}
{{- $delegation := $httpRoute.delegation | default dict -}}
{{- $delegationHostnames := $delegation.hostnames | default list -}}
{{- if not $delegationHostnames -}}
{{- $delegationHostnames = list (required "If delegation is enabled you must provide a serverUrl: set matrix.delegation.serverUrl (used as the delegation HTTPRoute's single hostname when httpRoute.delegation.hostnames is empty) or set httpRoute.delegation.hostnames explicitly" .Values.matrix.delegation.serverUrl) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute
spec:
{{- /*
Its own parent, falling back to the homeserver route's. The server name may
well be published by a different Gateway than the homeserver host - it is a
domain somebody else's website usually owns - and in that case this is where
it is said.
*/}}
parentRefs:
{{- toYaml ($delegation.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
{{- toYaml $delegationHostnames | nindent 4 }}
rules:
{{- /*
PathPrefix and not Exact, and the same path the Ingress uses: the
delegation nginx serves two documents below it
(/.well-known/matrix/server and /.well-known/matrix/client), so the
prefix has to carry both. NO URLRewrite filter - the delegation nginx's
own config answers on the full path, so nothing is stripped, which is
also why the #118 prefix/redirect assurance has no precondition to meet
here.
*/}}
- matches:
- path:
type: PathPrefix
value: /.well-known/matrix
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service
port: 80
{{- end }}
{{- end }}
+43 -2
View File
@@ -119,10 +119,11 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Gates the whole Ingress template (issue #93). false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints.", "description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints and which the HTTPRoute reads as its hostname.",
"type": "boolean" "type": "boolean"
}, },
"domain": { "domain": {
"description": "The homeserver hostname. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by the delegation nginx, which serves it inside /.well-known/matrix/client - so it is required in every mode while delegation is on.",
"type": ["string", "null"] "type": ["string", "null"]
}, },
"clusterIssuer": { "clusterIssuer": {
@@ -132,13 +133,53 @@
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.", "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. INERT IN GATEWAY MODE: the Gateway API has no annotation channel. This installation's live Ingress carries proxy-body-size 50m (which has no counterpart and must be re-established at the Gateway) and a configuration-snippet of more_set_headers directives (which must NOT be re-established - see values.yaml, httpRoute block).",
"type": "object" "type": "object"
} }
} }
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, not one: the Ingress is a single object with two host rules (homeserver at /, delegation at /.well-known/matrix), and an HTTPRoute's hostnames apply to the whole object, so two hostnames wanting different paths and backends must be two objects. NOT COVERED IN THIS MODE: proxy-body-size, which this installation carries at 50m and which Synapse's own max_upload_size has to agree with - see values.yaml, httpRoute block, including why the configuration-snippet on the live object is NOT a loss. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the homeserver route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the homeserver hostname has a single source of truth. Does NOT cover the delegation host - see httpRoute.delegation.hostnames.",
"type": "array",
"items": { "type": "string" }
},
"delegation": {
"description": "The delegation route, rendered while matrix.delegation.enabled is true - the same switch the Ingress's second host rule uses.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"parentRefs": {
"description": "Empty falls back to httpRoute.parentRefs. Worth setting when the server name is published by a different Gateway than the homeserver host, which is the normal case - the server name is an apex domain somebody else's website usually owns.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [matrix.delegation.serverUrl]. Deliberately does NOT fall back to httpRoute.hostnames: the server name is what remote homeservers resolve and is a different thing from the homeserver's hostname, and the migration order depends on telling them apart.",
"type": "array",
"items": { "type": "string" }
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"resources": { "resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.", "description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
"type": ["object", "null"], "type": ["object", "null"],
+123
View File
@@ -155,10 +155,133 @@ ingress:
# read by the delegation nginx, which serves it in the /.well-known/matrix/* # read by the delegation nginx, which serves it in the /.well-known/matrix/*
# endpoints, so it stays required whenever matrix.delegation.enabled is true. # endpoints, so it stays required whenever matrix.delegation.enabled is true.
# Only clusterIssuer becomes unnecessary. # Only clusterIssuer becomes unnecessary.
#
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
# true, and the message names both value paths.
enabled: true enabled: true
domain: null domain: null
clusterIssuer: null clusterIssuer: null
className: nginx className: nginx
# Extra annotations for the Ingress; merged with the cert-manager # Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets. # cluster-issuer annotation the chart always sets.
#
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
# controller behaviour. For THIS chart that is not a formality: read the
# httpRoute block below for what this installation actually has in here and
# which half of it matters.
annotations: {} annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true. The chart renders ONLY HTTPRoutes with parentRefs: no Gateway, no
# class, no Certificate, and no implementation-specific policy object. Class
# and certificate sit on the Gateway and its listeners and belong to the
# platform (cluster-config), which is what keeps the Gateway implementation
# replaceable without touching this chart again.
#
# TWO ROUTES, not one. The Ingress is a single object with two HOST rules - the
# homeserver on ingress.domain at "/", and the delegation nginx on
# matrix.delegation.serverUrl at "/.well-known/matrix". An HTTPRoute's
# hostnames apply to the whole object, so two hostnames wanting different paths
# and different backends must be two objects. (#114 describes this chart as
# rendering its Ingresses in a loop over several apps; it does not - that is
# freesailarr.)
#
# ONE THING THIS MODE MAKES EASIER, and it is the awkward part of Matrix
# delegation: the server name is usually an apex domain that already serves a
# website. Under ingress-nginx two Ingress objects cannot both claim one
# hostname - the admission webhook rejects the second - which is why the
# delegation rule has to live inside this chart's single Ingress object.
# HTTPRoutes attached to the same listener for the same hostname are MERGED by
# the implementation, longest match winning, so here the /.well-known/matrix
# route can sit beside whatever owns that apex without either side knowing.
#
# WHAT THIS MODE COSTS, MEASURED ON THE LIVE OBJECT ON 2026-10-12, where the
# Ingress carried - all of it OPERATOR-supplied through ingress.annotations,
# since this chart sets none of it:
#
# nginx.ingress.kubernetes.io/proxy-body-size: 50m
# nginx.org/client-max-body-size: 50m
# nginx.ingress.kubernetes.io/configuration-snippet: |
# more_set_headers "X-Forwarded-For $remote_addr";
# more_set_headers "X-Forwarded-Proto $scheme";
# more_set_headers "Host $host:$server_port";
#
# 1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads
# and its own ceiling is max_upload_size in homeserver.yaml - which this
# chart cannot see, because the whole config comes from 1Password through
# secrets.configSecretRef. The effective limit is the smaller of the two,
# and in this mode the proxy half becomes THE GATEWAY'S default: neither
# number anyone can point at is necessarily the one that decides. The
# Gateway API has no body-size field, so per decision 2 of #114 this is
# documented rather than rebuilt. Set it next to the Gateway, in that
# implementation's own object, and set it to match max_upload_size.
# (nginx.org/client-max-body-size is the same limit spelled for NGINX Inc's
# ingress controller rather than ingress-nginx - a hedge across both - and
# it goes for the same reason.)
#
# 2. THE SNIPPET IS NOT LOAD-BEARING, WHICH TOOK CHECKING. It looks like what
# Synapse's x_forwarded setting depends on - client IP for rate limiting,
# and federation. It is not: more_set_headers is the OUTPUT-header directive
# of headers-more-nginx-module ("Replaces (if any) or adds (if not any) the
# specified output headers"), while the request-side directive is
# more_set_input_headers. These three headers are therefore added to the
# RESPONSE TO THE CLIENT; Synapse never sees them, and what it reads with
# x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx
# sets itself.
#
# So for the migration: not translatable, and not needed. The Gateway API's
# ResponseHeaderModifier can set static response headers but knows no nginx
# variables ($remote_addr, $scheme, $host), so rebuilding it is impossible
# anyway - and pointless. Do NOT re-create it at the Gateway in the belief
# that Synapse needs it.
#
# Stated as what it is: a reading of the directive's documented semantics.
# Nothing here observed a Synapse instance with and without the snippet.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the HOMESERVER route attaches to - a platform object,
# usually in another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the homeserver hostname has one source of
# truth. Set explicitly only to publish under a different or an additional
# name - which is how a migration from the Ingress runs: the HTTPRoute takes
# a second hostname while the Ingress keeps ingress.domain.
#
# THE DELEGATION HOST IS NOT COVERED BY THIS LIST, deliberately - see below.
hostnames: []
delegation:
# The delegation route's own parent. Empty falls back to
# httpRoute.parentRefs. Worth setting when the SERVER NAME is published by
# a different Gateway than the homeserver host, which is the normal case:
# the server name is an apex domain somebody else's website usually owns.
parentRefs: []
# Empty means [matrix.delegation.serverUrl], and it deliberately does NOT
# fall back to httpRoute.hostnames. The server name is a different thing
# from the homeserver's hostname - it is what REMOTE homeservers resolve -
# and the migration order depends on telling them apart: move the
# homeserver host first, confirm it answers, move the server name last.
# A chart-global hostname list covering both would erase that distinction.
# Set this only when the server name itself has to move.
hostnames: []
+1 -1
View File
@@ -25,7 +25,7 @@ type: application
# byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml # byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml
# - measured, both empty diffs. New optional values with unchanged defaults is # - measured, both empty diffs. New optional values with unchanged defaults is
# exactly the minor case (README, "Versioning"). # exactly the minor case (README, "Versioning").
version: 1.1.1+upv0.16.25 version: 1.2.0+upv0.16.25
appVersion: "v0.16.25" appVersion: "v0.16.25"
maintainers: maintainers:
+123 -8
View File
@@ -1,9 +1,27 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $release := include "stalwart.fullname" . -}} {{- $release := include "stalwart.fullname" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}} {{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}} {{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}} {{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
the paragraphs below that asked ingress.enabled were really asking whether the
WebUI and JMAP are reachable from outside at all (Gitea #114).
*/ -}}
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $ingressGroup := "" -}} {{- $ingressGroup := "" -}}
{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}} {{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
@@ -140,14 +158,18 @@ FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
'Cluster role "<name>" not found in registry', and it is printed AFTER the 'Cluster role "<name>" not found in registry', and it is printed AFTER the
listeners are already bound. listeners are already bound.
4. Configure HTTP forwarding before the auto-ban notices the Ingress. 4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP
request, not the client's. Sooner or later the auto-ban bans that one request, not the client's. Sooner or later the auto-ban bans that one
address and the WebUI and JMAP go dark for everyone at once. The PROXY address and the WebUI and JMAP go dark for everyone at once. The PROXY
protocol is not the fix for HTTP: enable "forwarded" handling with protocol is not the fix for HTTP: enable "forwarded" handling with
X-Forwarded-For evaluation, and discard forwarding headers the client X-Forwarded-For evaluation, and discard forwarding headers the client
brought itself. The outage arrives weeks later and looks like a server brought itself. The outage arrives weeks later and looks like a server
fault, which is why this is a step and not a tip. fault, which is why this is a step and not a tip.
{{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not
reset it: it is one proxy address instead of another, and whether the
Gateway sends an X-Forwarded-For at all is that implementation's business,
not something this route can state.{{ end }}
5. Point the TLS object at the mounted certificate. 5. Point the TLS object at the mounted certificate.
{{- if .Values.certificate.enabled }} {{- if .Values.certificate.enabled }}
@@ -295,7 +317,7 @@ DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
{{- end }} {{- end }}
{{- if .Values.ingress.enabled }} {{- if .Values.ingress.enabled }}
WEB UI AND JMAP WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}" https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer: Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
@@ -305,10 +327,101 @@ WEB UI AND JMAP
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
database (step 3). Between finishing setup and creating that listener the database (step 3). Between finishing setup and creating that listener the
Ingress resolves to a port that answers nothing. Ingress resolves to a port that answers nothing.
{{- else if $httpRoute.enabled }}
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
→ node group "{{ $ingressGroup }}", one route, not one per group
This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing
else of the HTTP routing path — no Gateway, no class, no certificate for this
hostname, no policy object. Three things therefore belong to the cluster:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows only in
the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment; an empty status means no
controller has looked at the route at all.
3. The TLS certificate for the HTTP hostname, on the Gateway's listener.
ingress.clusterIssuer is not needed FOR THAT — but read the next
paragraph before removing it.
THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP.
{{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders
{{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }}
That object has nothing to do with the Ingress or the Gateway: it covers
STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS
ports, and those connections pass through no HTTP proxy at all. So in this mode
ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and
ingress.clusterIssuer is still required unless certificate.clusterIssuer is
set. Deleting the Certificate as part of a migration drops SMTP and IMAP back
to Stalwart's self-signed certificate: clients see a name mismatch while
every Kubernetes object in this release stays green.
SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not
routed by the Ingress. They keep arriving through the per-port NodePort
Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this
chart renders neither.
AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL.
This installation's live Ingress carries, set by the operator through
ingress.annotations and NOT by this chart:
nginx.ingress.kubernetes.io/enable-cors: true
nginx.ingress.kubernetes.io/cors-allow-origin: https://<the webmail host>
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
That is what lets the browser-side webmail (the bulwark chart) call this
server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is
entirely on the client side: a console error in the browser, and nothing
whatsoever in this server's logs.
The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors`
field in the CRDs this repository pins — so this is not a missing feature of
the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly:
- the chart has no CORS values to render it from (the origin and the method
list are the operator's, and the chart sets no CORS annotations in
Ingress mode either),
- that filter's support level is "Extended", so an implementation may
reject the route — and rejection is whole-route, which would take the
WebUI and JMAP down together — or, worse, accept it and not honour it,
- and the part a plain header filter COULD do is the part that does not
help here: the browser's PREFLIGHT has to be answered by the gateway
rather than by Stalwart, and because the allowed headers include
Authorization, EVERY request from the webmail is preflighted.
So: configure CORS AT THE GATEWAY, with that implementation's own support,
BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl
sends no preflight, so it will pass while the webmail fails.
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname
the WebUI sends administrators to lives IN THE DATABASE, not in any value
here. Upstream moves administration to https://<the configured server
hostname>/admin once setup is complete, so this route only works while an
HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3).
{{- else }} {{- else }}
NO INGRESS NEITHER INGRESS NOR HTTPROUTE
ingress.enabled is false, so there is no Ingress and no WebUI certificate. ingress.enabled and httpRoute.enabled are both false, so there is no Ingress,
no HTTPRoute and no WebUI certificate.
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
dnsNames, and without it the certificate would carry no name at all. dnsNames, and without it the certificate would carry no name at all.
{{- end }} {{- end }}
@@ -363,6 +476,8 @@ NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
that never becomes Ready reads as a broken application rather than as a that never becomes Ready reads as a broken application rather than as a
policy. The port carries no nodePort, so this is open inside the cluster, not policy. The port carries no nodePort, so this is open inside the cluster, not
from outside. Narrow it with networkPolicy.probeSources, and then add the from outside. Narrow it with networkPolicy.probeSources, and then add the
ingress controller's namespace to networkPolicy.allowedNamespaces or the {{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
Ingress goes dark. traffic arrives from the Gateway's data-plane pods, which usually live in a
namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the
{{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark.
{{- end }} {{- end }}
+165
View File
@@ -0,0 +1,165 @@
{{/*
HTTPRoute - the Gateway-API alternative to stalwart.ingress.yaml (Gitea #114).
ONE ROUTE, NOT ONE PER NODE GROUP, and that is worth saying because #114
describes this chart as rendering "per node group". Its SERVICES and
StatefulSets are per group; the routing object is not and must not be. The
Ingress targets exactly ONE group (ingress.nodeGroup) through that group's own
http Service, because the WebUI's setup and management screens are not safe to
spread over a group that may not run the management tasks. The route keeps that
property, including the render abort when the named group does not expose
"http".
WHAT STAYS IN THIS MODE, AND IT IS THE ONLY CHART IN THE SWEEP WHERE ANYTHING
DOES: the MAIL certificate. stalwart.certificate.yaml is not the Ingress
certificate - it covers STARTTLS on 25 and implicit TLS on 465/993, and those
connections never pass through an ingress controller or a Gateway. So
certificate.enabled keeps rendering a Certificate here, ingress.domain stays
required (the certificate's dnsNames read it) and ingress.clusterIssuer stays
required unless certificate.clusterIssuer is set. Do NOT "clean up" the
Certificate when switching to Gateway mode: SMTP and IMAP would fall back to
whatever self-signed certificate Stalwart generates, and clients would see a
name mismatch while every Kubernetes object stayed green.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate FOR THE HTTP HOSTNAME, and no implementation-specific policy
object - the subset that looks identical on nginx-gateway-fabric, Cilium, Envoy
Gateway and Istio (decision 1 of #114). The HTTP hostname's certificate belongs
on the Gateway's listener; the mail certificate stays here, which is the
distinction above.
And the Gateway API routes HTTP only, so SMTP, IMAP and ManageSieve are as far
outside this object as they are outside the Ingress - they keep reaching the
pods through the per-port NodePort Services. A Gateway could in principle carry
them as TLSRoute/TCPRoute listeners, which this chart does not render and which
would be a separate decision.
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND THIS IS THE ONE PLACE
IN GROUP 3 WHERE THE LOSS BREAKS A SECOND CHART OF THE SAME GROUP
On 2026-10-12 the live Ingress (jk-cluster, namespace stalwart,
mail.bekemeyer-klein.de) carried, all of it OPERATOR-supplied through
ingress.annotations - this chart sets none of it:
nginx.ingress.kubernetes.io/enable-cors: true
nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
That origin is BULWARK, the webmail chart in this same group. The CORS
configuration is the whole reason the browser-side webmail may call this
server's JMAP endpoint at all, so it is not decoration: without it the webmail
stops working, with a console error and no server-side symptom.
WHAT THE GATEWAY API HAS FOR IT, MEASURED AGAINST THE PINNED CRDs RATHER THAN
ASSUMED. Both halves exist in the API, and neither is promisable by this chart:
- A full CORS FILTER exists. In the standard channel this repository pins
(gateway-api v1.6.3, ci/gateway-api-crds.sh), HTTPRoute's filters carry a
`cors` field with allowOrigins, allowMethods, allowHeaders,
allowCredentials and maxAge - GEP-1767. Grepped out of the downloaded CRD,
not inferred. Its documented support level, verbatim from that CRD, is
"Support: Extended".
- responseHeaderModifier is in the same CRD and is ALSO "Support: Extended",
not Core.
So "no counterpart" would be the wrong thing to write down. The accurate
statement is that there is a counterpart and this chart still does not render
it, for three reasons, in order:
1. THE CHART HAS NO CORS VALUES AT ALL. The live configuration is the
operator's: a specific origin, a specific method list. Rendering a CORS
filter means inventing a values surface in a sweep PR - and one that
would exist in Gateway mode only, because this chart sets no CORS
annotations in Ingress mode either. That asymmetry is its own defect and
its own piece of work.
2. EXTENDED SUPPORT IS NOT SUPPORT. #119 established the shape of this: an
implementation that does not support an Extended value must report it in
the route's status, and a rejected route is rejected WHOLE - here that
would take the WebUI and JMAP down together. Worse, the observed failure
is quieter than that: Cilium accepts `filters.type: CORS` and still
forwards the browser's preflight upstream
(cilium/cilium#45272), i.e. the object is Accepted and the behaviour is
absent. A chart cannot check which implementation it landed on.
3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
ONE HERE. A responseHeaderModifier can add the three
Access-Control-Allow-* headers to a response, but the PREFLIGHT is a
different thing: the browser sends OPTIONS first and the gateway - not the
backend - has to answer it. The CORS filter's own contract says so (a path
with a CORS filter MUST NOT pass the preflight upstream), which is exactly
what a bare header modifier cannot do. And this installation's header list
includes Authorization, which makes EVERY webmail request preflighted: the
half that is expressible without the CORS filter is precisely the half
that does not help. Rendering it would produce a setup that looks
configured and fails on the first real call.
So: named, not rebuilt (decision 2 of #114), in the Gateway branch of
NOTES.txt, with the instruction to configure CORS at the Gateway - via that
implementation's CORS support - BEFORE moving this host, and to test it from
the webmail rather than with curl, because curl sends no preflight.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Stalwart there is a second edge to it: the server's auto-ban sees the proxy's address and bans it, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and note that the server hostname configured IN THE DATABASE decides where the WebUI tells administrators to go, which no chart value can change." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $group := include "stalwart.ingressNodeGroup" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $spec := index $nodes $group -}}
{{- if not (has "http" $spec.exposedPorts) -}}
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the HTTPRoute to point at. The value is shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress." $group $group) -}}
{{- end -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- /*
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
read outside the routing templates in this chart - the mail Certificate's
dnsNames - so it already carries a required() there and is mandatory in every
mode, including with both modes off. This required() is the route's own, so the
message names the route.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "stalwart.fullname" . }}-httproute
labels:
app: {{ include "stalwart.fullname" . }}
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the http
Service of the "{{ $group }}" group ONLY - never a port-label selector,
for the reason in the header. The port is the Service's port NUMBER and
not its name "http": backendRefs takes a PortNumber, so unlike the
Ingress this reference cannot be made by name. It comes from
ports.http.containerPort, which is the same value the Service renders its
port from, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port: {{ int (index $ports "http").containerPort }}
{{- end }}
+26 -3
View File
@@ -390,7 +390,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.", "description": "Gates the Ingress template only. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1) - and the HTTPRoute reads it as its hostname as well. clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
"type": "boolean" "type": "boolean"
}, },
"clusterIssuer": { "clusterIssuer": {
@@ -405,16 +405,34 @@
"type": ["string", "null"] "type": ["string", "null"]
}, },
"nodeGroup": { "nodeGroup": {
"description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.", "description": "Which node group the routing object - Ingress or HTTPRoute - points at, through that group's own http Service. Shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress. Must be a key of nodes: and must expose http; both are checked in both templates. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"annotations": { "annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. INERT IN GATEWAY MODE, and for this chart that is the consequential one: this installation's live Ingress carries its CORS configuration here (enable-cors plus allow-origin/methods/headers for the bulwark webmail). See values.yaml, httpRoute block - the Gateway API does have a CORS filter, and the chart still does not render it.",
"type": "object" "type": "object"
} }
} }
}, },
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; the HTTP hostname's certificate sits on the Gateway's listener and belongs to the platform. ONE route, not one per node group - it targets ingress.nodeGroup's http Service, with the same two guards the Ingress has. THE MAIL CERTIFICATE STAYS in this mode: it covers STARTTLS on 25 and implicit TLS on 465/993, which pass through no HTTP proxy, so ingress.domain stays required and the Certificate must not be removed as part of a migration. SMTP, IMAP and ManageSieve are not routed here any more than by the Ingress. NOT COVERED IN THIS MODE: the CORS configuration this installation relies on for the bulwark webmail. A CORS filter DOES exist in the pinned gateway-api CRDs (v1.6.3, GEP-1767) but is 'Support: Extended', implementations vary (Cilium accepts it and forwards the preflight upstream anyway), the chart has no CORS values to render it from, and a bare responseHeaderModifier cannot answer a preflight - which is every request here, since the allowed headers include Authorization. Configure CORS at the Gateway and test from a browser, not with curl. See values.yaml, httpRoute block.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the DATABASE, not in any chart value: after bootstrap the WebUI is reached at https://<configured server hostname>/admin, so a second hostname can be reachable without the application agreeing that it is its name.",
"type": "array",
"items": { "type": "string" }
}
}
},
"securityContext": { "securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.", "description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
"type": ["object", "null"], "type": ["object", "null"],
@@ -455,6 +473,11 @@
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"exposedPorts": { "exposedPorts": {
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.", "description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
"type": "array", "type": "array",
+129
View File
@@ -625,8 +625,137 @@ ingress:
nodeGroup: full nodeGroup: full
# Extra annotations for the Ingress; merged with the cert-manager # Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets. # cluster-issuer annotation the chart always sets.
#
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
# controller behaviour. For THIS chart that is the most consequential line in
# the whole block: read the httpRoute section below before switching modes,
# because what lives in here on the live installation is the CORS
# configuration the webmail depends on.
annotations: {} annotations: {}
# ---------------------------------------------------------------------------
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
# ---------------------------------------------------------------------------
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY an
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
# hostname, and no implementation-specific policy object.
#
# ONE ROUTE, NOT ONE PER NODE GROUP. #114 describes this chart as rendering
# "per node group"; its SERVICES and StatefulSets are, the routing object is
# not and must not be. The route targets exactly the ingress.nodeGroup group,
# through that group's own http Service, and aborts the render if that group
# does not expose "http" - the same two guards the Ingress has, for the same
# reason: the WebUI's setup and management screens are not safe to spread over
# a group that may not run the management tasks.
#
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
# anything does. certificate.enabled renders a Certificate for STARTTLS on 25
# and implicit TLS on 465/993; those connections pass through neither an
# ingress controller nor a Gateway. So in Gateway mode:
# - ingress.domain is still REQUIRED (the certificate's dnsNames read it),
# - ingress.clusterIssuer is still required unless certificate.clusterIssuer
# is set,
# - and the Certificate must NOT be "cleaned up" as part of the migration.
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
# certificate: clients see a name mismatch while every Kubernetes object in the
# release stays green.
#
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
# routed by the Ingress. They keep reaching the pods through the per-port
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
# this chart renders neither, and doing so would be a separate decision.
#
# WHAT THIS MODE COSTS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
# Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
# all of it OPERATOR-supplied through ingress.annotations (this chart sets none
# of it):
#
# nginx.ingress.kubernetes.io/enable-cors: true
# nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
# nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
# nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
#
# That origin is BULWARK, the webmail chart in this same group. The CORS
# configuration is what allows the browser-side webmail to call this server's
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
# error in the browser and NO server-side symptom whatsoever.
#
# AND "no counterpart" would be the wrong thing to claim here. Measured against
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
# inference:
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
# Its documented support level, verbatim from that CRD: "Support:
# Extended".
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
# Extended", not Core.
#
# There IS a counterpart, then, and this chart still does not render it. Three
# reasons, in order:
#
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
# - a specific origin, a specific method list. Rendering a filter means
# inventing a values surface in a sweep PR, and one that would exist in
# Gateway mode only, since the chart sets no CORS annotations in Ingress
# mode either. That asymmetry is its own defect and its own piece of work.
# 2. EXTENDED SUPPORT IS NOT SUPPORT. An implementation that does not support
# an Extended value reports it in the route's status, and a rejected route
# is rejected WHOLE - which here would take the WebUI and JMAP down
# together (the reasoning #119 arrived at for statusCode). The observed
# failure is quieter still: Cilium accepts filters.type: CORS and
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
# i.e. Accepted: True with the behaviour absent. A chart cannot know which
# implementation it landed on.
# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
# ONE HERE. A responseHeaderModifier can add the three
# Access-Control-Allow-* headers to a response. The PREFLIGHT is a
# different thing: the browser sends OPTIONS first and the GATEWAY, not
# the backend, has to answer it - the CORS filter's own contract says a
# path carrying it MUST NOT pass the preflight upstream. A bare header
# modifier cannot do that. And this installation's header list includes
# Authorization, which makes EVERY webmail request preflighted: the half
# that is expressible without the CORS filter is precisely the half that
# does not help.
#
# So configure CORS AT THE GATEWAY, using that implementation's own CORS
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
# curl: curl sends no preflight, so it passes while a browser fails.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
# Set explicitly only to publish under a different or an additional name -
# which is how a migration from the Ingress runs: the HTTPRoute takes a
# second hostname while the Ingress keeps ingress.domain.
#
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
# bootstrap the WebUI is reached at https://<the configured server
# hostname>/admin, and no value of this chart changes that - so a second
# hostname is reachable without the application agreeing that it is its name.
hostnames: []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Hardening — chart-wide, applied to every node group # Hardening — chart-wide, applied to every node group
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/matszwe02/ytdlp_web_player/main/src/stat
type: application type: application
version: 1.0.1+upv1.2.0 version: 1.1.0+upv1.2.0
appVersion: "v1.2.0" appVersion: "v1.2.0"
maintainers: maintainers:
+98 -15
View File
@@ -1,4 +1,23 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $replicas := include "ytdlp-web-player.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.ytdlpWebPlayer.replicas) -}} {{- $replicas := include "ytdlp-web-player.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.ytdlpWebPlayer.replicas) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, so
every paragraph that asked ingress.enabled was really asking whether this
chart publishes the application (Gitea #114). Same helper the host guard uses.
*/ -}}
{{- $published := eq (include "ytdlp-web-player.published" .) "true" -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $ownHost := first $hostnames -}}
{{- $res := fromYaml (include "ytdlp-web-player.effectiveResources" .Values.ytdlpWebPlayer.resources) -}} {{- $res := fromYaml (include "ytdlp-web-player.effectiveResources" .Values.ytdlpWebPlayer.resources) -}}
{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}} {{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
{{- $startup := fromYaml (include "ytdlp-web-player.startupProbe" .Values.ytdlpWebPlayer.startupProbe) -}} {{- $startup := fromYaml (include "ytdlp-web-player.startupProbe" .Values.ytdlpWebPlayer.startupProbe) -}}
@@ -22,23 +41,66 @@ ACCESS — THROUGH THE PROXY
way into the application. Certificate and TLS secret are the subchart's too — way into the application. Certificate and TLS secret are the subchart's too —
this chart's ingress.clusterIssuer does not reach it; set the cert-manager this chart's ingress.clusterIssuer does not reach it; set the cert-manager
annotation under oauth2-proxy.ingress.annotations. annotation under oauth2-proxy.ingress.annotations.
AN INGRESS, AND IT STAYS ONE IN BOTH OF THIS CHART'S MODES. The subchart has
no Gateway-API support and this chart cannot give it one (upstream's chart is
consumed as it comes), so httpRoute.enabled on THIS chart does not move the
proxy off its Ingress. In this configuration — the recommended one — turning
it on buys nothing.
{{- else if .Values.ingress.enabled }} {{- else if .Values.ingress.enabled }}
ACCESS ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }} https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
cluster issuer. Until it has been issued the browser shows a warning; follow it with cluster issuer. Until it has been issued the browser shows a warning; follow it with
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders ONE HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and nothing
else of the routing path — no Gateway, no class, no Certificate, no policy
object. Three things therefore belong to the cluster:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret.
ingress.annotations is inert in this mode — no annotation channel in the
Gateway API. Note which annotation that is NOT about: the 16k
proxy-buffer-size this chart recommends belongs on the PROXY's Ingress, and
the subchart renders an Ingress whatever this chart does, so it is
unaffected.
{{- else }} {{- else }}
ACCESS — NO INGRESS ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled is false, so this release has no Ingress and no certificate. ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate.
Both ingress.domain and ingress.clusterIssuer are unnecessary here — unlike Both ingress.domain and ingress.clusterIssuer are unnecessary here — unlike
in some other charts in this repo, nothing outside the Ingress template reads in some other charts in this repo, nothing but the two routing templates
the domain: this application builds no absolute URLs and has no base-URL reads the domain: this application builds no absolute URLs and has no
setting. Reach it inside the cluster: base-URL setting. Reach it inside the cluster:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80 kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
@@ -48,14 +110,16 @@ ACCESS — NO INGRESS
SCALED TO ZERO SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}ytdlpWebPlayer.replicas: 0{{ end }} — the Deployment runs with replicas: 0, so nothing answers {{ if .Values.scaleDown }}scaleDown: true{{ else }}ytdlpWebPlayer.replicas: 0{{ end }} — the Deployment runs with replicas: 0, so nothing answers
until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and Ingress stay in place, until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and Ingress stay in place,
so the certificate is not re-issued.{{ else }} stays in place.{{ end }} Nothing is lost that was not so the certificate is not re-issued.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the
route keeps pointing at a Service with no endpoints — the Gateway answers
with an error of its own rather than with nothing.{{ else }} stays in place.{{ end }} Nothing is lost that was not
going to be lost anyway: the video cache is an emptyDir (see below). going to be lost anyway: the video cache is an emptyDir (see below).
{{- if $proxy.enabled }} {{- if $proxy.enabled }}
Note that scaleDown does NOT reach the subchart — the proxy keeps running and Note that scaleDown does NOT reach the subchart — the proxy keeps running and
will answer with a gateway error. Set oauth2-proxy.replicaCount: 0 as well. will answer with a gateway error. Set oauth2-proxy.replicaCount: 0 as well.
{{- end }} {{- end }}
{{ end }} {{ end }}
{{- if and .Values.ingress.enabled (not $proxy.enabled) }} {{- if and $published (not $proxy.enabled) }}
THIS RELEASE IS OPEN TO EVERYONE WHO KNOWS THE URL THIS RELEASE IS OPEN TO EVERYONE WHO KNOWS THE URL
The application has NO authentication of any kind, and this chart adds none The application has NO authentication of any kind, and this chart adds none
while oauth2-proxy.enabled is false. What an anonymous visitor can do here is while oauth2-proxy.enabled is false. What an anonymous visitor can do here is
@@ -73,22 +137,41 @@ THIS RELEASE IS OPEN TO EVERYONE WHO KNOWS THE URL
enabled: true enabled: true
ingress: ingress:
enabled: true enabled: true
hosts: ["{{ .Values.ingress.domain }}"] hosts: ["{{ $ownHost }}"]
ingress: ingress:
enabled: false # the proxy owns the host from then on enabled: false # the proxy owns the host from then on
{{- if $httpRoute.enabled }}
httpRoute:
enabled: false # and so does this one: the SUBCHART HAS NO
# GATEWAY SUPPORT, so the proxy publishes over
# an Ingress either way. Leaving this on for the
# same host is refused by the chart; leaving it
# on for a different host is the bypass below.
{{- end }}
Anything else in front of it — an ingress-nginx auth annotation, a network Anything else in front of it — an ingress-nginx auth annotation, a network
policy, a VPN-only ingress class — has to come from ingress.annotations or policy, a VPN-only ingress class — has to come from ingress.annotations or
from outside this chart. from outside this chart{{ if $httpRoute.enabled }}, and NONE of it is available in Gateway mode:
there is no annotation channel, so an auth annotation has no counterpart.
Authentication would have to come from the Gateway itself or from the proxy{{ end }}.
{{- end }} {{- end }}
{{- if and $proxy.enabled .Values.ingress.enabled }} {{- if and $proxy.enabled $published }}
WARNING — THE APP'S OWN INGRESS IS A WAY PAST THE PROXY WARNING — THE APP'S OWN {{ if $httpRoute.enabled }}ROUTE{{ else }}INGRESS{{ end }} IS A WAY PAST THE PROXY
Both oauth2-proxy.enabled and ingress.enabled are true. The chart only Both oauth2-proxy.enabled and {{ if $httpRoute.enabled }}httpRoute.enabled{{ else }}ingress.enabled{{ end }} are true. The chart only
refuses this when both claim the SAME host; here they do not, so it renders — refuses this when both claim the SAME host; here they do not, so it renders —
but https://{{ .Values.ingress.domain }} reaches the application directly, with no but https://{{ $ownHost }} reaches the application directly, with no
authentication in front of it, whatever the proxy does on its own host. authentication in front of it, whatever the proxy does on its own host.
Unless that is deliberate, set ingress.enabled=false. Unless that is deliberate, set {{ if $httpRoute.enabled }}httpRoute.enabled=false{{ else }}ingress.enabled=false{{ end }}.
{{- if $httpRoute.enabled }}
AND IN THIS MODE THE SAME-HOST CASE IS THE ONE WORTH FEARING, which is why
the chart refuses it: ingress-nginx's admission webhook compares Ingress
objects with Ingress objects, so it never sees an HTTPRoute. Two valid
objects would then serve one hostname — the proxy's Ingress through
authentication, this chart's route straight to the application — and which
one a client reaches would depend on DNS alone, with nothing reporting it.
{{- end }}
{{- end }} {{- end }}
{{- if $proxy.enabled }} {{- if $proxy.enabled }}
+87 -12
View File
@@ -343,19 +343,72 @@ http://{{ .Release.Name }}-{{ .Chart.Name }}-service:80
{{- end -}} {{- end -}}
{{/* {{/*
Refuse to render two Ingresses for the same host (issue #214). Does this release publish the application itself, in either mode?
With the proxy enabled, this chart's Ingress and the subchart's Ingress can ytdlp-web-player.published = ingress.enabled OR httpRoute.enabled
both be switched on, and both then claim the same host. That is not a merge and
not a warning: ingress-nginx's admission webhook rejects whichever arrives Added with the Gateway-API support (Gitea #114), and it is the same shape as
second with "host ... and path ... is already defined in ingress ...". Under freesailarr.published and sure.publishedOverTls. It exists because
Fleet the bundle goes to ErrApplied and Fleet does not retry a rejected apply httpRoute.enabled is an ALTERNATIVE to ingress.enabled, so in Gateway mode
by itself - getting out costs a forceSyncGeneration bump on the GitRepo. ingress.enabled is false BY CONSTRUCTION - and every reader of
Failing here costs a re-render. ingress.enabled outside the Ingress template would silently get the wrong
answer in that mode. In this chart there was exactly one such reader, the host
guard below, and it was the guard that is needed MOST in Gateway mode. See its
comment.
Identical to ingress.enabled while httpRoute.enabled is false, which is why
introducing it changes no existing release's render.
*/}}
{{- define "ytdlp-web-player.published" -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if or .Values.ingress.enabled $httpRoute.enabled -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/*
Refuse to publish the application and the proxy on the same host (issue #214,
extended for Gitea #114).
With the proxy enabled, this chart's own routing object and the SUBCHART's
Ingress can both be switched on, and both then claim the same host. What
happens next depends on the mode, and the Gateway-mode half is the dangerous
one:
INGRESS MODE - this chart's Ingress against the subchart's Ingress. Not a
merge and not a warning: ingress-nginx's admission webhook rejects
whichever arrives second with "host ... and path ... is already defined in
ingress ...". Under Fleet the bundle goes to ErrApplied and Fleet does not
retry a rejected apply by itself - getting out costs a forceSyncGeneration
bump on the GitRepo. Loud, at least.
GATEWAY MODE - this chart's HTTPRoute against the subchart's Ingress. THE
WEBHOOK DOES NOT FIRE AT ALL: it compares Ingress objects with Ingress
objects, and these are two different kinds. So nothing rejects anything,
both objects are valid, and one hostname is served over two data paths.
And the two are not equivalent - the proxy's Ingress goes through
AUTHENTICATION and this chart's HTTPRoute goes STRAIGHT TO THE
APPLICATION. Which one a client reaches is a question of what DNS resolves
to, so the failure mode is an authentication bypass that appears and
disappears with a DNS record. Nothing in Kubernetes reports it.
That is why this guard now asks ytdlp-web-player.published rather than
ingress.enabled: in Gateway mode the old condition was false and the guard was
silent, exactly where it mattered more.
THE SUBCHART HAS NO GATEWAY SUPPORT, and that is not something this chart can
fix - upstream's chart is consumed as it comes (README, "Chart dependencies").
So in the RECOMMENDED configuration, where the proxy sits in the data path and
owns the host, the publishing object is the subchart's INGRESS whatever this
chart's mode is. Switching this chart to httpRoute there buys nothing; the
chart's own routing object is for the other case, the app on a host of its own
or the proxy switched off.
Host-based rather than "both switched on", because a proxy on a host of its own Host-based rather than "both switched on", because a proxy on a host of its own
collides with nothing. That case is not silently blessed either: NOTES.txt says collides with nothing. That case is not silently blessed either: NOTES.txt says
plainly that the app's own Ingress is then a way past the proxy. plainly that the app's own route is then a way past the proxy.
Called from the Deployment - the one template that always renders - so the Called from the Deployment - the one template that always renders - so the
guard cannot be skipped by scaling to zero or by rendering a single file with guard cannot be skipped by scaling to zero or by rendering a single file with
@@ -364,9 +417,31 @@ guard cannot be skipped by scaling to zero or by rendering a single file with
{{- define "ytdlp-web-player.validate" -}} {{- define "ytdlp-web-player.validate" -}}
{{- $proxy := (index .Values "oauth2-proxy") | default dict -}} {{- $proxy := (index .Values "oauth2-proxy") | default dict -}}
{{- $proxyIngress := $proxy.ingress | default dict -}} {{- $proxyIngress := $proxy.ingress | default dict -}}
{{- if and .Values.ingress.enabled $proxy.enabled $proxyIngress.enabled -}} {{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if has .Values.ingress.domain ($proxyIngress.hosts | default list) -}} {{- if and (eq (include "ytdlp-web-player.published" .) "true") $proxy.enabled $proxyIngress.enabled -}}
{{- fail (printf "ingress.enabled and oauth2-proxy.ingress.enabled both claim the host %q. ingress-nginx allows only one Ingress per host and path; its admission webhook rejects the second one, which leaves a Fleet bundle in ErrApplied until forceSyncGeneration is bumped. Decide who owns the host: with the proxy in the data path set ingress.enabled=false (the app is then reached through the proxy and its Service), or drop the proxy's Ingress with oauth2-proxy.ingress.enabled=false." .Values.ingress.domain) -}} {{- /*
The hostnames this chart publishes on: ingress.domain in Ingress mode, and in
Gateway mode httpRoute.hostnames or - when that is empty - ingress.domain,
which is the same single source of truth the route itself uses.
*/ -}}
{{- $ownHosts := list -}}
{{- if $httpRoute.enabled -}}
{{- $ownHosts = $httpRoute.hostnames | default list -}}
{{- if not $ownHosts -}}
{{- $ownHosts = list .Values.ingress.domain -}}
{{- end -}}
{{- else -}}
{{- $ownHosts = list .Values.ingress.domain -}}
{{- end -}}
{{- $proxyHosts := $proxyIngress.hosts | default list -}}
{{- range $host := $ownHosts -}}
{{- if has $host $proxyHosts -}}
{{- if $httpRoute.enabled -}}
{{- fail (printf "httpRoute.enabled and oauth2-proxy.ingress.enabled both claim the host %q, and in this mode NOTHING REJECTS IT. ingress-nginx's admission webhook compares Ingress objects with Ingress objects; this chart renders an HTTPRoute, so the two kinds never meet and both objects stay valid. One hostname is then served over two data paths that are not equivalent: the proxy's Ingress goes through AUTHENTICATION, this chart's HTTPRoute goes straight to the application. Which one a client reaches depends on what DNS resolves to, so this is an authentication bypass that comes and goes with a DNS record and that no Kubernetes object reports. Decide who owns the host: with the proxy in the data path set httpRoute.enabled=false (the app is then reached through the proxy and its Service - note the subchart has no Gateway support, so the proxy publishes over an Ingress either way), or drop the proxy's Ingress with oauth2-proxy.ingress.enabled=false, or give this chart a host of its own via httpRoute.hostnames and accept that it bypasses the proxy." $host) -}}
{{- else -}}
{{- fail (printf "ingress.enabled and oauth2-proxy.ingress.enabled both claim the host %q. ingress-nginx allows only one Ingress per host and path; its admission webhook rejects the second one, which leaves a Fleet bundle in ErrApplied until forceSyncGeneration is bumped. Decide who owns the host: with the proxy in the data path set ingress.enabled=false (the app is then reached through the proxy and its Service), or drop the proxy's Ingress with oauth2-proxy.ingress.enabled=false." $host) -}}
{{- end -}}
{{- end -}}
{{- end -}} {{- end -}}
{{- end -}} {{- end -}}
{{- end -}} {{- end -}}
@@ -0,0 +1,134 @@
{{/*
HTTPRoute - the Gateway-API alternative to ytdlp-web-player.ingress.yaml
(Gitea #114).
READ THE SUBCHART PARAGRAPH FIRST, because it decides whether this mode is
useful at all for a given installation.
THE oauth2-proxy SUBCHART HAS NO GATEWAY SUPPORT, AND THIS CHART CANNOT GIVE
IT ONE. Upstream's chart is consumed as it comes - that is the standing rule
for dependencies (README, "Chart dependencies"): where its defaults fall short
of this repository's conventions, the shortfall is DOCUMENTED and never
patched. It renders an Ingress or nothing.
So in the RECOMMENDED configuration - oauth2-proxy.enabled true, the proxy in
the data path, the proxy owning the host and this chart's own routing object
switched off - the object that publishes the release is the SUBCHART'S
INGRESS, in both of this chart's modes. Setting httpRoute.enabled there buys
nothing and changes nothing about how the host is served. This route is for
the other two cases:
- the proxy switched off entirely (oauth2-proxy.enabled false), the chart
publishing the application itself;
- the application on a host of ITS OWN, next to the proxy's host - which is
allowed and means exactly what it says: an unauthenticated way past the
proxy. NOTES.txt says so in both modes.
AND THAT SECOND CASE IS WHY THE HOST GUARD CHANGED. ytdlp-web-player.validate
used to ask ingress.enabled, which is false by construction in this mode, so
the guard went silent exactly where it matters most. It now asks
ytdlp-web-player.published (ingress.enabled OR httpRoute.enabled). The
difference is not cosmetic: in Ingress mode a host claimed twice is rejected
LOUDLY by ingress-nginx's admission webhook, while in Gateway mode the webhook
never fires - it compares Ingress objects with Ingress objects, and an
HTTPRoute is a different kind. Two valid objects then serve one hostname over
two data paths, one through authentication and one straight to the
application, and which one a client reaches depends on DNS. See the helper's
comment for the full argument.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object - the subset that looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
listeners, in cluster-config.
WHAT THIS MODE DROPS, AND WHAT IT DOES NOT
ingress.annotations is inert here - no annotation channel in the Gateway API.
For this chart the measured picture is unusual, so it is worth being exact
about which annotation lives where:
- The 16k proxy-buffer-size this chart's values recommend is NOT on this
chart's Ingress. It belongs on the PROXY's Ingress
(oauth2-proxy.ingress.annotations), because it is the second half of the
4kB session-cookie trap: the response carrying the proxy's session cookie
does not fit ingress-nginx's default 4k buffer and the browser gets a 502
AFTER a successful login. The subchart still renders an Ingress in this
mode, so that annotation is UNAFFECTED by this switch. It would only be
lost if the proxy itself ever moved behind a Gateway, which this chart
cannot do.
- This chart's own ingress.annotations default is {} and the chart sets
nothing but the cert-manager annotation, so there is no chart-side loss to
name.
NOTHING WAS MEASURED ON A LIVE INGRESS OF THIS CHART, because there is none.
Checked on 2026-10-12: the release runs in namespace jlab-player (Services
jlab-player-ytdlp-web-player-service and jlab-player-oauth2-proxy, both
Deployments Ready) and NOT ONE of the 23 Ingress objects in the cluster is in
that namespace - neither this chart's nor the subchart's. What publishes it
instead could NOT be determined: this workspace's identity may not list
httproutes or secrets, so neither a hand-written HTTPRoute nor the release's
own Helm values are readable. Stated as the open question it is, rather than
guessed at.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. ingress.enabled defaults to TRUE in this chart, so turning the Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and if the oauth2-proxy subchart is in the data path, mind that it has no Gateway support and publishes over an Ingress regardless." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- include "ytdlp-web-player.validate" . -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- 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 -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart.
AND THIS READ CHANGES THE CHART'S OWN ACCOUNT OF ITSELF. values.yaml and the
schema both stated that everything the Ingress needs is read only inside the
Ingress template, which is why switching it off freed BOTH ingress.domain and
ingress.clusterIssuer. The second half still holds - the application has no
base-URL setting and builds no absolute links, so it needs no hostname of its
own - but ingress.domain is now read HERE as well. Both have been corrected
rather than left standing. clusterIssuer is still freed by this mode: no
Certificate is rendered.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the
application Service. The port is the Service's port NUMBER and not its
name "http": backendRefs takes a PortNumber, so unlike the Ingress this
reference cannot be made by name. 80 is what the Service publishes (and
what ytdlp-web-player.appServiceAddress hands the proxy as its
upstream), so the three agree; ci/run-httproute-match-test.sh holds the
pair for the case where they stop agreeing.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+27 -3
View File
@@ -72,30 +72,54 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": { "enabled": {
"description": "Gates this chart's whole Ingress template (#93). Here it frees BOTH domain and clusterIssuer: nothing outside the Ingress template reads either of them - the application needs no hostname of its own, so the searxng/matrix-synapse case of a domain read from an env block does not arise (guideline 1, checked in templates/ and in values.yaml).", "description": "Gates this chart's whole Ingress template (#93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. CORRECTED with Gitea #114: it used to say this frees BOTH domain and clusterIssuer because nothing outside the Ingress template reads either. clusterIssuer is still freed (no Certificate is rendered in either mode by anything but the Ingress), but ingress.domain is read by the HTTPRoute as well, so it is freed only when BOTH modes are off. What still holds is the reason the claim was made: the application needs no hostname of its own, so the searxng/matrix-synapse case of a domain read from an env block does not arise.",
"type": "boolean" "type": "boolean"
}, },
"clusterIssuer": { "clusterIssuer": {
"type": ["string", "null"] "type": ["string", "null"]
}, },
"domain": { "domain": {
"description": "The public hostname. Read by the Ingress and by the HTTPRoute (when httpRoute.hostnames is empty), and by nothing else - the application takes no base URL.",
"type": ["string", "null"] "type": ["string", "null"]
}, },
"className": { "className": {
"type": ["string", "null"] "type": ["string", "null"]
}, },
"annotations": { "annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. Inert in Gateway mode. Note this is NOT where the 16k proxy-buffer-size belongs: that one goes on the PROXY's Ingress (oauth2-proxy.ingress.annotations), and the subchart renders an Ingress in either mode, so it is unaffected by the switch.",
"type": "object" "type": "object"
} }
} }
}, },
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. WHAT IT DOES NOT DO: the oauth2-proxy subchart has NO Gateway support and this chart cannot give it one (upstream's chart is consumed as it comes), so in the recommended configuration - proxy in the data path, proxy owning the host - the publishing object is the SUBCHART'S INGRESS whatever this value says. This route is for the proxy-disabled case or for the application on a host of its own, which is an unauthenticated way past the proxy. THE HOST GUARD IS SHARPER HERE, NOT WEAKER: if this chart and the proxy's Ingress claim one host, the ingress-nginx admission webhook never fires (it compares Ingress with Ingress, and an HTTPRoute is another kind), so two valid objects serve one hostname over two data paths - one authenticated, one not - and which a client reaches depends on DNS. The chart refuses that combination in both modes via the ytdlp-web-player.published helper. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. The host guard reads this list too, so a hostname set here that the proxy's Ingress also claims is caught rather than silently published twice.",
"type": "array",
"items": { "type": "string" }
}
}
},
"oauth2-proxy": { "oauth2-proxy": {
"description": "Values of the oauth2-proxy subchart, passed straight through. Open on purpose and at every level: this chart owns neither the keys nor their meaning, and a closed copy would reject whatever the subchart adds in its next release - the exact failure mode a schema is supposed to prevent, only pointed at the wrong chart. This chart reads exactly three of them: enabled (the dependency condition), ingress.enabled and ingress.hosts (the two-Ingress guard).", "description": "Values of the oauth2-proxy subchart, passed straight through. Open on purpose and at every level: this chart owns neither the keys nor their meaning, and a closed copy would reject whatever the subchart adds in its next release - the exact failure mode a schema is supposed to prevent, only pointed at the wrong chart. This chart reads exactly three of them: enabled (the dependency condition), ingress.enabled and ingress.hosts (the host guard, which since Gitea #114 compares them against this chart's hostnames in EITHER mode). The subchart has no Gateway-API support and renders an Ingress or nothing, which is why httpRoute.enabled on this chart does not move the proxy off its Ingress.",
"type": "object" "type": "object"
} }
}, },
"definitions": { "definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"resources": { "resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (#99) instead of rendering a container without resource accounting.", "description": "May be null / empty: the chart then falls back to its default requests and computed limits (#99) instead of rendering a container without resource accounting.",
"type": ["object", "null"], "type": ["object", "null"],
+93 -5
View File
@@ -232,11 +232,18 @@ storage:
cacheSizeLimit: null cacheSizeLimit: null
ingress: ingress:
# Gates this chart's own Ingress. Everything the Ingress needs is read only # Gates this chart's own Ingress. MUTUALLY EXCLUSIVE WITH httpRoute.enabled -
# inside its template - the application itself needs no hostname of its own # the render aborts when both are true, and the message names both value
# (no base-URL setting, no absolute links, no url_for(_external=True)), so # paths.
# switching the Ingress off frees BOTH ingress.domain and #
# ingress.clusterIssuer instead of only the issuer. # CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this used to say that
# everything the Ingress needs is read only inside its template, and that
# switching the Ingress off therefore freed BOTH ingress.domain and
# ingress.clusterIssuer. The second half still holds - the application needs
# no hostname of its own (no base-URL setting, no absolute links, no
# url_for(_external=True)) and this mode renders no Certificate, so
# clusterIssuer is still freed. But ingress.domain is now read by the
# HTTPRoute as well, so it is freed only when BOTH modes are off.
# #
# WHO OWNS THE HOST, WITH THE PROXY ENABLED: the proxy sits in the data path, # WHO OWNS THE HOST, WITH THE PROXY ENABLED: the proxy sits in the data path,
# so with oauth2-proxy.enabled=true the Ingress belongs to the PROXY # so with oauth2-proxy.enabled=true the Ingress belongs to the PROXY
@@ -252,8 +259,89 @@ ingress:
className: nginx className: nginx
# Extra annotations for the Ingress; merged with the cert-manager # Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets. # cluster-issuer annotation the chart always sets.
#
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
# controller behaviour. Note which annotation this is NOT about: the 16k
# proxy-buffer-size this chart recommends belongs on the PROXY's Ingress
# (oauth2-proxy.ingress.annotations), and the subchart keeps rendering an
# Ingress in either mode, so that one is unaffected by the switch.
annotations: {} annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true. The chart renders ONLY an HTTPRoute with parentRefs: no Gateway, no
# class, no Certificate, and no implementation-specific policy object. Class
# and certificate sit on the Gateway and its listeners and belong to the
# platform (cluster-config).
#
# BEFORE SWITCHING THIS ON, KNOW WHAT IT DOES NOT DO. The oauth2-proxy
# SUBCHART HAS NO GATEWAY SUPPORT and this chart cannot give it one - upstream's
# chart is consumed as it comes (README, "Chart dependencies"), so it renders an
# Ingress or nothing. In the RECOMMENDED configuration, where the proxy sits in
# the data path and owns the host while this chart's own routing object is off,
# the object that publishes the release is THE SUBCHART'S INGRESS whatever this
# value says. Turning httpRoute on there buys nothing.
#
# This route is for the other two cases:
# - the proxy switched off (oauth2-proxy.enabled: false), so this chart
# publishes the application itself;
# - the application on a host of ITS OWN beside the proxy's host, which is
# allowed and means exactly what it says: an unauthenticated way past the
# proxy.
#
# AND THE HOST GUARD IS SHARPER IN THIS MODE, NOT WEAKER. If this chart and the
# proxy's Ingress claim the SAME host:
# - in Ingress mode ingress-nginx's admission webhook rejects the second
# object loudly (the Fleet bundle goes to ErrApplied);
# - in Gateway mode the webhook NEVER FIRES - it compares Ingress objects
# with Ingress objects, and an HTTPRoute is a different kind. Both objects
# stay valid and one hostname is served over two data paths that are NOT
# equivalent: the proxy's Ingress goes through authentication, this chart's
# HTTPRoute goes straight to the application. Which one a client reaches
# depends on DNS, so it is an authentication bypass that comes and goes
# with a record and that nothing in Kubernetes reports.
# The chart therefore refuses to render that combination in BOTH modes, via the
# ytdlp-web-player.published helper (ingress.enabled OR httpRoute.enabled).
# Before #114 that guard asked ingress.enabled, which is false by construction
# in Gateway mode - it would have gone silent exactly where it matters most.
#
# NOTHING WAS MEASURED ON A LIVE INGRESS OF THIS CHART, because there is none.
# Checked 2026-10-12: the release runs in namespace jlab-player with both
# Deployments Ready, and not one of the cluster's 23 Ingress objects is in that
# namespace - neither this chart's nor the subchart's. What publishes it
# instead could not be determined from this workspace (its identity may not
# list httproutes or secrets). An open question, not a guess.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs, and also how the application is
# given a host of its own beside the proxy's. The host guard reads this list
# too, so a hostname set here that the proxy's Ingress also claims is caught.
hostnames: []
# --- oauth2-proxy subchart ------------------------------------------------- # --- oauth2-proxy subchart -------------------------------------------------
# #
# Everything below this line is passed to the oauth2-proxy chart # Everything below this line is passed to the oauth2-proxy chart