Chart-Style-Guide: group 3 of the Gateway-API sweep, and what it added to the convention
The sweep is finished bar rss-bridge, so the "remaining" paragraph now says so - and carries a table of the three descriptions #114 gave of the group-3 shapes that turned out to be wrong, because two of them had already been copied into README and into a CI script's header where they read as settled fact. The general lesson is written next to it: a shape read off an issue's summary table is a hypothesis, not a measurement. Three new rules in the §4 table: - a chart without ingress.domain omits hostnames ENTIRELY and its schema rejects the key (freesailarr, and now homeassistant too); - several routing objects where the Ingress had several hosts or several rules with different backends, because spec.hostnames applies to the whole object - with the per-object parentRefs fallback as the counterpart of ingress.<x>.className falling back to ingress.className; - index-based object names where the count is data-driven, and the price: reordering the list recreates rather than edits. Three new "no counterpart" entries: the LIVE-OBJECT screen (four of group 3's six charts carried an operator annotation no git grep of this repo finds); reading the directive before calling an annotation load-bearing (more_set_headers is the output-header directive, so matrix-synapse's snippet never reaches Synapse); and "no counterpart" being a claim to check against the pinned CRDs, which for CORS is false - the filter exists, is Extended, and is still not rendered, with the general rule that a chart renders a filter only when it has values for it AND the support level makes the behaviour promisable. Plus pathType ImplementationSpecific, which is a chart default in homeassistant. The ingress.enabled-derived screen is closed at three finds, and the entry now makes the point group 3 taught: ask what the derived value PROTECTS. In ytdlp-web-player the guard pre-empted a noisy webhook in Ingress mode and became, in Gateway mode where the webhook cannot fire, the only thing between a values file and an authentication bypass. Also: a subchart does not come along (oauth2-proxy has no Gateway support), and stalwart still rendering a Certificate in Gateway mode is correct.
1 file changed
+35
-6
+35
-6
@@ -35,7 +35,7 @@ See [Versioning](Versioning) for the full rules. Short form: `version: <chart-se
|
||||
|
||||
**A shared domain with sub-paths is the second admissible shape.** Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (`example.com/sonarr`, `example.com/radarr`). Rules for the shared shape: the domain is a single value (`ingress.host`); each app's path is its own value defaulting to `/<app>`; the paths render in one Ingress object with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the TLS secret name and **omitting** the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work. **In Gateway mode that extra object is not needed**: a `URLRewrite` filter is per rule, so the prefix-stripping app is simply one rule of the shared route — see below.
|
||||
|
||||
**Documented exception — homeassistant** keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `className` default follows the standard.
|
||||
**Documented exception — homeassistant** keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `className` default follows the standard. **The exception extends to the Gateway mode**: with no `ingress.domain` there is no `httpRoute.hostnames` either, `ingress.hosts` is read in both modes, and the chart renders one route per entry — see the Gateway rules below.
|
||||
|
||||
**Documented exception — rss-bridge** takes `ingress.hosts` as a required list of host plus optional `tlsSecretName`, so several hosts keep their own existing certificates.
|
||||
|
||||
@@ -61,21 +61,50 @@ The rest of the rules, each against a failure that reports nothing:
|
||||
| **A value, never auto-detection** | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the Gateway-API CRDs fails at *install* time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
| **Mutually exclusive with `ingress.enabled`**, render abort naming **both** value paths | two data paths onto one Service under one hostname is a conflict at DNS level already. A migration runs over a **second hostname** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
|
||||
| **`parentRefs` mandatory in Gateway mode**, render abort on the empty list | a route without a parent attaches to nothing: created, schema-valid, rejected by nobody, routing nothing — no event, no condition, the only symptom a hostname that does not answer. |
|
||||
| **One source of truth for the hostname** — empty `hostnames` means `[ingress.domain]` | two values that have to agree drift apart. `ingress.domain` is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. A chart without an `ingress.domain` omits `hostnames` and keeps its existing host values — `freesailarr` is the one such case. |
|
||||
| **One source of truth for the hostname** — empty `hostnames` means `[ingress.domain]` | two values that have to agree drift apart. `ingress.domain` is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. A chart without an `ingress.domain` **omits `hostnames` entirely** and keeps its existing host values — and its schema rejects the key, so the absence is enforced rather than merely intended. Two such charts: `freesailarr` (`ingress.host` / `<app>.ingress.host`) and `homeassistant` (`ingress.hosts`, the flexible list). |
|
||||
| **Several routing objects where the Ingress had several hosts, or several rules with different backends** | `HTTPRoute.spec.hostnames` applies to the **whole object**, so one route can carry several hostnames only when they want the *same* rules. A chart whose hosts want different paths or different backends renders **one object per host**: `keycloak` (public and admin hostname), `matrix-synapse` (homeserver at `/`, delegation nginx at `/.well-known/matrix`), `homeassistant` (one per `ingress.hosts` entry). Where the hostnames differ in *kind* rather than merely in value, the per-object parent gets its own value falling back to the chart-global one — `httpRoute.admin.parentRefs`, `httpRoute.delegation.parentRefs`. That fallback is the counterpart of `ingress.<x>.className` falling back to `ingress.className`, because **"which data path" is a property of the Gateway in this API, not of the route**. |
|
||||
| **Index-based object names where the count is data-driven** | a hostname is not a dependable object-name fragment (the 63-character label limit plus the release prefix; a sanitised hostname can collide). `homeassistant` names its routes `…-httproute`, `…-httproute-1`, … by list index — with the consequence written down rather than discovered: **reordering the list renames routes**, so they are recreated rather than edited. Acceptable only because a route holds no state and no certificate. |
|
||||
| **A chart that still renders its Ingress unconditionally gets an `ingress.enabled: true` first** | without a second value path there is nothing for the mutual-exclusion abort to name, and the Ingress plus its unconditional `required()` on `clusterIssuer` would keep rendering in Gateway mode — a value demanded for nothing. `paperless-ngx` was the last such chart and got the switch with group 2; the default keeps the render identical. |
|
||||
| **`NOTES.txt` names the active mode** and what it costs, as `keycloak` already does for its cases | a mode nobody can see from the outside |
|
||||
|
||||
**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. The five charts that set one (`bulwark`, `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`) must print in their Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway, because whoever overlooks that silently inherits the Gateway's default and meets it as a proxy error instead of an application error. All but `bulwark` carry that disclosure since group 2, each in the chart's own terms rather than in one copied sentence. The same holds for 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` is the live example.
|
||||
**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. Every chart that sets or recommends one must print in its Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway, because whoever overlooks that silently inherits the Gateway's default and meets it as a *proxy* error instead of an application error: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` since group 2, `bulwark` and `matrix-synapse` since group 3 — each in the chart's own terms rather than in one copied sentence. The same holds for 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` is the live example.
|
||||
|
||||
**And the screen is "needs one", not "sets one".** `garage` sets `annotations: {}` and still depends on an upload limit, because ingress-nginx truncates S3 uploads at 1 MB; `freesailarr` sets none and needs none itself, but `ingress.annotations` is the only lever its operator has for one. Both say so in their Gateway-mode notes. Read values comments and `NOTES.txt`, not only templates.
|
||||
|
||||
**`rewrite-target` is the one place where this mode is *less* machinery, not a translation of the old one.** Per object versus per rule: the shared-host shape above needs a second Ingress object for a prefix-stripping app, which then repeats a TLS secret name, omits the cert-manager annotation conditionally, and matches an `ImplementationSpecific` regex path. In Gateway mode it is one rule with a filter — and the four crutches are gone. Measured on `freesailarr`.
|
||||
**And the third screen is the LIVE object — group 3's lesson, and the one that cannot be done from inside the repository.** A chart can read as correct while the running system fills a gap of the chart with an annotation the **operator** set, and Gateway mode drops `ingress.annotations`, so it drops the compensation with it. Measured with read-only `kubectl` before writing group 3's branches: **four of its six charts carried such an annotation that the chart never sets** — `keycloak` (`proxy-buffer-size: 16k`, `proxy-buffers-number: 4`, on *both* its Ingress objects), `matrix-synapse` (`proxy-body-size: 50m` plus the `nginx.org/client-max-body-size` twin plus a `configuration-snippet`), `stalwart` (the whole `enable-cors` block), `bulwark` (`proxy-body-size: 25m`). **None appears in a `git grep` of this repository** — the only hits are the documentation recommending two of them. So: *before* writing a chart's Gateway branch, read its live objects. See [Lessons learned](Lessons-Learned).
|
||||
|
||||
**A value derived from `ingress.enabled` is the sweep's second screen, and it is invisible in the Ingress template.** Gateway mode sets `ingress.enabled: false` by construction, so anything computed from it flips. Where that value stood for "is TLS terminated in front" or "is this app published at all", the flipped value is wrong and nothing fails. The fix is one helper reading `ingress.enabled OR httpRoute.enabled` — never a new value. Found twice so far: `sure` (`RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL`) and `freesailarr` (the shared-host app list, which also feeds the `<APP>__SERVER__URLBASE` variables). See [Lessons learned](Lessons-Learned).
|
||||
**Read the annotation's directive before calling it load-bearing.** `matrix-synapse`'s live `configuration-snippet` looks like exactly what Synapse's `x_forwarded` depends on. It is not: `more_set_headers` is the headers-more module's **output**-header directive, the request-side one being `more_set_input_headers`, so those headers reach the client and never Synapse. The chart's notes therefore say **not** to re-create it at the Gateway. An annotation that looks frightening and does nothing costs as much review time as one that matters.
|
||||
|
||||
**"No counterpart" is a claim to check against the pinned CRDs.** For CORS it is false: HTTPRoute's filters carry a full `cors` field (GEP-1767) in the set this repo pins, and `responseHeaderModifier` sits beside it — **both `Support: Extended`, neither Core**. `stalwart` still renders neither, and the reasoning is the general rule: a chart renders a filter only when it has values to render it from *and* the support level makes the behaviour promisable. Extended is not promisable — a rejected route is rejected **whole**, and the observed failure is quieter still (Cilium accepts `filters.type: CORS` and forwards the preflight upstream anyway). **And half a feature is worse than none**: a `responseHeaderModifier` can add the `Access-Control-Allow-*` headers but cannot answer the **preflight**, which the gateway rather than the backend must do — and with `Authorization` in the allowed headers every request is preflighted, so the expressible half is exactly the useless one.
|
||||
|
||||
**`pathType: ImplementationSpecific` has no counterpart either.** Ingress has `Exact`/`Prefix`/`ImplementationSpecific`, the Gateway API `Exact`/`PathPrefix`/`RegularExpression`. The first two map; the third means "ask the controller" and nothing preserves it. The rule: render it as `PathPrefix` **on `/` only** (every controller agrees there, so a chart defaulting to it keeps working) and **abort the render on any other path**, because an `ImplementationSpecific` sub-path is a *regex* in ingress-nginx and `PathPrefix` is not one — mapping it silently would change matching while the render stayed green. `RegularExpression` is not an escape hatch: its support level is Implementation-specific. `homeassistant` is the chart this applies to, and it is a chart **default** there.
|
||||
|
||||
**`rewrite-target` is one of two places where this mode is *less* machinery, not a translation of the old one.** Per object versus per rule: the shared-host shape above needs a second Ingress object for a prefix-stripping app, which then repeats a TLS secret name, omits the cert-manager annotation conditionally, and matches an `ImplementationSpecific` regex path. In Gateway mode it is one rule with a filter — and the four crutches are gone. Measured on `freesailarr`.
|
||||
|
||||
**The second such place, found in group 3: two routing objects may share a hostname.** Under ingress-nginx two Ingress objects cannot both claim one host — the admission webhook rejects the second claim on the same host and path — which is why `matrix-synapse` has to carry its `/.well-known/matrix` delegation rule *inside* its own single Ingress object even though the Matrix server name is normally an apex domain belonging to a website. HTTPRoutes attached to the same listener for the same hostname are **merged** by the implementation, longest match winning, so that route can sit beside whatever owns the apex without either side knowing.
|
||||
|
||||
**A value derived from `ingress.enabled` is the sweep's second screen, and it is invisible in the Ingress template.** Gateway mode sets `ingress.enabled: false` by construction, so anything computed from it flips. Where that value stood for "is TLS terminated in front" or "is this app published at all", the flipped value is wrong and nothing fails. The fix is one helper reading `ingress.enabled OR httpRoute.enabled` — never a new value. **Found three times, which is every chart that had the pattern**: `sure` (`RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL`), `freesailarr` (the shared-host app list, which also feeds the `<APP>__SERVER__URLBASE` variables) and `ytdlp-web-player` (the guard refusing to publish the app and its oauth2-proxy on one host).
|
||||
|
||||
**And that last one is the case worth remembering, because the guard mattered *more* in the new mode.** A host claimed twice fails loudly in Ingress mode — the admission webhook rejects the second Ingress. 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 that are **not equivalent** — the proxy's Ingress through authentication, the chart's route straight to the application — and which one a client reaches depends on DNS. So a render guard whose Ingress-mode job was to pre-empt a noisy webhook becomes, in Gateway mode, the **only** thing standing between a values file and an authentication bypass. When screening for this pattern, ask what the derived value *protects*, not only what it computes. See [Lessons learned](Lessons-Learned).
|
||||
|
||||
**A subchart does not come along.** Upstream charts are consumed as they come (§14), so a dependency that renders an Ingress keeps rendering one whatever the outer chart's mode is. `ytdlp-web-player`'s oauth2-proxy has no Gateway-API support, so in the recommended configuration — proxy in the data path, owning the host — the publishing object is the **subchart's Ingress** in both modes, and switching the outer chart buys nothing. Say so where an operator will look for it: the template header, `values.yaml`, the schema and the relevant `NOTES.txt` branch.
|
||||
|
||||
**And the CI side is part of the convention, not an afterthought.** Either the chart's install-test fixture installs in Gateway mode, or the chart gets a render-only overlay at `ci/httproute/<chart>.yaml`. Without one of the two, `ci/run-httproute-match-test.sh` is green over nothing for that chart — which that test fails on. For a multi-app chart the overlay must also enter **every** shape the template has, or the branch nobody exercised is the one that breaks. See [CI/CD](CI-CD#5-httproute-match-every-chart-every-pr).
|
||||
|
||||
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Done so far: the pilot (`cyberchef`), group 1 — one Ingress, no behaviour-bearing annotation — (`bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`) and group 2 — the annotation cases — (`calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`, `freesailarr`). Remaining: group 3, the special shapes (`matrix-synapse` renders in a loop, `keycloak` and `bulwark` have two Ingresses each, `homeassistant` is the documented ingress exception, `stalwart` renders per node group, `ytdlp-web-player` hangs off the oauth2-proxy subchart), plus `rss-bridge`, which is frozen for changes — which is why #114 cannot be closed while #51 is open.
|
||||
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. **The sweep is finished except for one chart.** The pilot (`cyberchef`); group 1 — one Ingress, no behaviour-bearing annotation — (`bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`); group 2 — the annotation cases — (`calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`, `freesailarr`); group 3 — the special shapes — (`homeassistant`, `keycloak`, `bulwark`, `matrix-synapse`, `stalwart`, `ytdlp-web-player`). Remaining: **`rss-bridge` only**, frozen for changes — which is why #114 cannot be closed while #51 is open.
|
||||
|
||||
**Three of #114's descriptions of the group-3 shapes were wrong, and the corrected list belongs here because this is what gets copied:**
|
||||
|
||||
| #114 said | Measured |
|
||||
|---|---|
|
||||
| `keycloak` and `bulwark` have **two** Ingresses each | `keycloak` does. **`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 own *refutations* of the idea. `freesailarr` is still the only `rewrite-target` chart. |
|
||||
| `matrix-synapse` renders its Ingresses **in a loop over several apps** | That is `freesailarr`. `matrix-synapse` renders **one** Ingress with **two host rules**, which becomes two `HTTPRoute` objects. |
|
||||
| `stalwart` renders **per node group** | Its Services and StatefulSets do; the routing object points at exactly one group (`ingress.nodeGroup`) and must. |
|
||||
|
||||
**The general lesson from that table**, and it is the cheaper half of the live-object screen above: a shape read off an issue's summary table is a hypothesis. Open the template before building on it — three of four were wrong here, and two of the three had already been copied into this file and into a CI script's header comment, where they read as settled fact.
|
||||
|
||||
**One chart still renders a `Certificate` in Gateway mode, and that is correct**: `stalwart`'s mail certificate covers STARTTLS on 25 and implicit TLS on 465/993, which pass through neither an ingress controller nor a Gateway. Only the HTTP hostname's certificate moves to the listener. A migration that "tidies up" the Certificate drops SMTP and IMAP to a self-signed one while every Kubernetes object stays green.
|
||||
|
||||
## 5. Security hardening
|
||||
|
||||
|
||||
Reference in new issue
Block a user