Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b136de004
|
||
|
|
6d79d8f270
|
||
|
|
e4b41577ae
|
||
|
|
b10800da2a
|
||
|
|
3e969c5659
|
||
|
|
e54cfe5fdd
|
||
|
|
9625d8b182
|
||
|
|
d0f0d49a93
|
||
|
|
d36112aaf6
|
||
|
|
7003f3674c
|
No files matched your search
@@ -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 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:
|
||||
|
||||
@@ -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.
|
||||
- **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)
|
||||
|
||||
@@ -255,7 +255,7 @@ What Renovate keeps current:
|
||||
| --- | --- |
|
||||
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
|
||||
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block. All ten arrive **grouped into one PR**, see "The one grouped chart" below |
|
||||
| GitHub Actions | Built-in `github-actions` manager |
|
||||
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
|
||||
|
||||
@@ -265,10 +265,29 @@ One rule those overrides share, learned from `searxng` and worth knowing before
|
||||
|
||||
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
|
||||
|
||||
**An open point this leaves, recorded rather than solved.** That automatic patch is also a classification, and the convention exempts a "plain app-version bump" from the wiki obligation — so an image update that quietly changes a *default* is the one case where nothing reviews it and nothing documents it. It has already happened once: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks, and it reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). Whether the convention should change is a separate decision and deliberately not proposed here.
|
||||
|
||||
Two dependencies are constrained beyond their versioning. **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning.
|
||||
|
||||
**linuxserver/qbittorrent** (freesailarr) excludes two tags by name, `14.3.9` and `20.04.1`. They are leftovers from earlier linuxserver tagging schemes, and docker versioning sorts both above every real release, so Renovate offers them as major updates — PRs #152 and #153 were exactly that. They are named rather than cut off with an upper bound like `<10`, because a bound would eventually reject a genuine major, while two finished artifacts cannot grow and therefore cannot hide a future release. The same guard as for nginx applies and was run: a lookup from `currentValue 4.6.4` still offers `5.2.3` as a **major**, so the filter does not silently empty the update list. That fixture also shows the cost of leaving it alone — without the rule the very same lookup offers `20.04.1` and never mentions the real `5.2.3`, since Renovate fills the major bucket only once.
|
||||
|
||||
### The one grouped chart
|
||||
|
||||
`freesailarr` is the only chart whose updates are **grouped**: all ten of its images arrive as one PR per cycle (`groupName: "freesailarr stack"`), where every other chart in this repo gets one PR per image. For the others that is already the same thing — one chart, one image — so this is a rule about a ten-image chart rather than a change of policy. It is also the only `groupName` in `renovate.json5`, and it reverses what the comment above the app-version managers describes: the `helm-values` manager finding those ten images and proposing "one update per image". That sentence described what Renovate does, not what the chart wants.
|
||||
|
||||
**What it fixes, measured on 2026-10-12.** Eight of the ten images had an open Renovate PR simultaneously (#102 bazarr, #103 qbittorrent, #104 sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr). Each edits `freesailarr/values.yaml` and — through the `postUpgradeTasks` hook — `freesailarr/Chart.yaml`, so the API reported `mergeable: false` for all eight: they rewrite the same `version:` line. One could merge; the other seven then needed a rebase each, and every rebase restarts the most expensive install-test here (a real Wireguard tunnel, ten containers). A sharper second symptom came with it: all eight carried `version: 3.1.1+up1.0.0 → 3.1.2+up1.0.0` while `main` already stood at `3.3.0+up1.0.0`, because the hook derives everything from the **base commit** and those branches predated the Gateway-API rounds. A rebase repairs that, which is precisely the problem — nothing *fails* if the rebase does not happen first, so the repair depends on the bot reaching the branch before a human reaches the merge button. Grouped, there is one branch, one `version:` computed from one base, one publish and one install-test run.
|
||||
|
||||
**Four decisions worth keeping** (the reasoning sits next to the rules in `renovate.json5`):
|
||||
|
||||
- **Matched by file name, not by a list of images.** What causes the collision is "touches `freesailarr/Chart.yaml`", true of every dependency Renovate finds in that `values.yaml` regardless of manager or datasource. A `matchPackageNames` list would be a second place to keep the chart's image set current, and its failure mode is silence — an image added later never joins the group, with no error (#158: a valid rule is not a matching rule).
|
||||
- **`mediathekarr` is in the group** although it comes from its own `customManagers` block: its digest PR edits the same two files and blocks the others identically. `digest` is in `matchUpdateTypes` for exactly that, and its own block is untouched — a group decides which *branch* an update lands on, not how it is found.
|
||||
- **`gluetun` and the `busybox` init image are in too.** Neither "gluetun is the tunnel" nor "busybox is only an init container" has anything to do with the blocking, and a grouped PR hides nothing: the review still sees every changed line, just not eight times over.
|
||||
- **Majors stay out**, via `matchUpdateTypes: ["patch", "minor", "digest"]`. An app major must be taken stepwise in its own PR, and `separateMultipleMajor` splits a multi-major jump to produce that sequence; a group throwing a major in with seven patches would undo both.
|
||||
|
||||
**It takes two `packageRules`, and that is not cosmetic.** Renovate refuses `matchUpdateTypes` and `separateMinorPatch` in one rule — `packageRules cannot combine both matchUpdateTypes and separateMinorPatch`, a config-validation error — which is consistent, since `separateMinorPatch` is what *decides* the split a rule would then be selecting on. So one rule groups the non-major update types and a second sets `separateMinorPatch: false` for the same file. Without that second rule the group still arrives as **two** PRs: verified by dry-run (`--platform=local --dry-run=full`, real registries) against a checkout holding those eight updates, they landed on `renovate/freesailarr-stack` (the three minors) and `renovate/patch-freesailarr-stack` (the five patches) — note that `separateMinorPatch` **prefixes** the branch rather than suffixing it, which makes the second name easy to miss in a branch list. With both rules, one branch carries all eight. The global `separateMinorPatch: true` keeps its reason for every other chart; "one red install-test must not block unrelated updates" simply does not apply within a single chart, where the updates share a pod, a chart version and a publish.
|
||||
|
||||
What this cannot show from inside the repo: Renovate runs as the shared instance outside it, so a valid config and a correct local lookup are the evidence available — not the behaviour of the next real run.
|
||||
|
||||
Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to major lines on purpose) and `template-chart/**` (placeholder image). A consequence worth knowing: `ci/matrix-synapse/fixtures.yaml` pins a Synapse image that Renovate will not follow — bump it by hand when the chart moves a major.
|
||||
|
||||
### The version-bump hook
|
||||
@@ -308,7 +327,7 @@ The config is aligned with the one in `cluster-config`, because the same `jlab-r
|
||||
|
||||
- **Automerge is global** (`automerge: true`, `automergeType: "pr"`, `platformAutomerge: true`) — every update type, **majors included**. The safeguard is not the absence of automerge, it is the branch protection: `main` requires a **review** and requires `validate-charts` to pass. `platformAutomerge` arms the PR on the forge, and Gitea merges it when *both* conditions are met, so an armed PR without a review simply sits there. This is the setting the older `platformAutomerge: false` comment in `renovate.json5` named as the better one "once required checks exist on `main`" — they now do. Same reasoning as `cluster-config`, which retired its dashboard-approval step for it.
|
||||
- **`minimumReleaseAge: "3 days"`** for `patch`, `minor` and `digest` updates. Renovate does not even open the PR before then. A merge here is never just a merge — a chart publish follows it straight away — so a release that gets yanked hours after it appeared must not reach the registry. Majors carry no age gate: they are held at the review, not by a delay.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates. **It has one exception**, `freesailarr`, where it is overridden to `false` — see "The one grouped chart" below for why that separation is wrong inside a single chart.
|
||||
- **No PR caps** (`prConcurrentLimit: 100`, `prHourlyLimit: 0`), as in `cluster-config`. The known cost, written down rather than discovered: the first run after a quiet week can open many PRs at once, and every chart PR starts a k3d install-test — which on a Gitea runner queues inside one job rather than fanning out (see "Where this repo lives"). That queue is the accepted trade for not having updates trickle in four per hour.
|
||||
- **`config:best-practices`** is in `extends`. Two of its members matter here: `helpers:pinGitHubActionDigests` moves the workflow files from `actions/checkout@v7` to SHA pins (wanted), and `docker:pinDigests` is switched off again for `custom.regex` and `helm-values` — an `appVersion:` is a tag by construction (`<repo>:{{ .Chart.AppVersion }}`, and the `+up` suffix is built from the same string), so a digest has nowhere to go. Sidecar digests in `values.yaml` *could* be pinned; that is a separate decision, since each digest change would be a chart patch plus a publish.
|
||||
- The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR.
|
||||
@@ -367,7 +386,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.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`.
|
||||
- **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.
|
||||
- **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 +405,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.
|
||||
- **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.
|
||||
- **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`).
|
||||
- **`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.
|
||||
- **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`).
|
||||
- **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:
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
### 5. Security hardening
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ type: application
|
||||
# duplicated here. The short version: eight tag shapes, six of them things
|
||||
# nobody should pin, and plain `docker` versioning excludes all six - so that
|
||||
# block deliberately carries NO versioningTemplate.
|
||||
version: 1.0.0+up1.12.0
|
||||
version: 1.1.0+up1.12.0
|
||||
appVersion: "1.12.0"
|
||||
|
||||
maintainers:
|
||||
|
||||
+87
-10
@@ -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" .) -}}
|
||||
{{- $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)) -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .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
|
||||
"WHAT THE PROBES DO NOT TELL YOU" below.
|
||||
|
||||
ENDPOINTS
|
||||
{{ if .Values.ingress.enabled }} Web: {{ $url }}
|
||||
{{ else }} Web: no Ingress (ingress.enabled: false) - in-cluster only
|
||||
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 or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
|
||||
{{ 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 }}
|
||||
{{ if .Values.ingress.enabled }} Admin: {{ $url }}/admin/login
|
||||
{{ else }} Admin: /admin/login on the in-cluster address above. With no Ingress, reach it with
|
||||
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
|
||||
{{ 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 }}
|
||||
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:
|
||||
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 }}
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
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
|
||||
every voluntary eviction and make `kubectl drain` hang forever on a pod
|
||||
nothing may move (README, guideline 11).
|
||||
- No sub-path Ingress, and no value that could add one - the base path is
|
||||
compiled into the image.
|
||||
- No sub-path Ingress and no sub-path route, and no value that could add
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
The honest position for THIS chart, checked in both directions as guideline 12.1
|
||||
asks: nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
|
||||
Bulwark derives its own public URL from the request origin and takes no base-URL
|
||||
variable, so with ingress.enabled: false the release is complete without a
|
||||
domain - the calibre-web-automated case, where the gate frees both values. The
|
||||
enforcement therefore sits entirely in bulwark.ingress.yaml, where it is reached
|
||||
by every render that has the Ingress on (the default), and this helper renders a
|
||||
visible placeholder instead of failing.
|
||||
asks, AND CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): it used to say
|
||||
that nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
|
||||
That was true and is no longer - bulwark.httproute.yaml reads it as the route's
|
||||
hostname, with a required() of its own. What still holds is the half that made
|
||||
the claim worth making: Bulwark derives its own public URL from the request
|
||||
origin and takes no base-URL variable, so with BOTH modes off the release is
|
||||
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" -}}
|
||||
{{- if .Values.ingress.domain -}}
|
||||
https://{{ .Values.ingress.domain }}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $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 -}}
|
||||
https://<ingress.domain NOT SET>
|
||||
{{- end -}}
|
||||
|
||||
@@ -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,12 +14,22 @@ Prefix, deliberately and permanently.
|
||||
|
||||
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:
|
||||
nothing outside this template FUNCTIONALLY reads ingress.domain. Bulwark derives
|
||||
its own public URL from the request origin and takes no base-URL environment
|
||||
variable - there is no KC_HOSTNAME equivalent here - so a release with
|
||||
ingress.enabled: false is complete without a domain. That is the
|
||||
Bulwark derives its own public URL from the request origin and takes no
|
||||
base-URL environment variable - there is no KC_HOSTNAME equivalent here - so a
|
||||
release with no routing object at all is complete without a domain. That is the
|
||||
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
|
||||
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
|
||||
|
||||
@@ -166,27 +166,50 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
|
||||
@@ -711,6 +711,11 @@ ingress:
|
||||
# away would produce a release that renders cleanly and whose operator is told
|
||||
# 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
|
||||
# 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
|
||||
@@ -734,4 +739,80 @@ ingress:
|
||||
# (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
|
||||
# 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: {}
|
||||
|
||||
# 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: []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -43,9 +43,18 @@
|
||||
# property of one route - a different route is a different hostname and
|
||||
# says nothing about this one. Exact outranks every prefix match in that
|
||||
# precedence, so the pairing is about existence and not about order.
|
||||
# freesailarr is the first chart this applies to; bulwark is the second
|
||||
# rewrite-target chart and meets the same check when group 3 of #114
|
||||
# lands, which is the point of checking it here rather than in one chart.
|
||||
# freesailarr is the FIRST AND, AS OF GROUP 3 OF #114, THE ONLY chart this
|
||||
# applies to. 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 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
|
||||
# cluster: schema validation accepts an empty parentRefs list and accepts a
|
||||
|
||||
@@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack
|
||||
|
||||
type: application
|
||||
|
||||
version: 3.3.0+up1.0.0
|
||||
version: 4.0.0+up1.0.0
|
||||
# The chart's own version marker, freely chosen and tied to no upstream. The
|
||||
# stack bundles ten independently versioned projects (nine of them on by
|
||||
# default), so there is no single "app version" to track here.
|
||||
|
||||
@@ -52,6 +52,45 @@ SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW
|
||||
fail, in a way that looks like an application bug. Set it to the cluster pod
|
||||
and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s.
|
||||
{{- end }}
|
||||
{{- /* The servarr apps that are published through a routing object AND have no
|
||||
trusted proxy network set - the one combination in which the empty
|
||||
default actually costs something. Gated on freesailarr.published plus
|
||||
membership in freesailarr.ingressApps, the same pair the ACCESS section
|
||||
below uses, so it holds in Gateway mode too: reading ingress.enabled
|
||||
directly would silence this warning in exactly the mode whose routes
|
||||
still publish the apps. */}}
|
||||
{{- $noTrust := list }}
|
||||
{{- if $published }}
|
||||
{{- range $a := $ingressApps }}
|
||||
{{- if and (has $a.name (list "prowlarr" "radarr" "sonarr")) (not (index $.Values $a.name).trustedNetworks) }}
|
||||
{{- $noTrust = append $noTrust $a.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if $noTrust }}
|
||||
|
||||
FORWARDED HEADERS ARE NOT TRUSTED — {{ join ", " $noTrust }}
|
||||
These apps are published through a routing object and their trustedNetworks
|
||||
value is empty, which is the chart's default. They therefore trust no proxy:
|
||||
the client address each one logs and displays is your ingress controller's
|
||||
pod IP rather than the real client, and each sees the scheme as http even
|
||||
where TLS is terminated in front of it. If an app's authentication is set to
|
||||
"Disabled for Local Addresses", a proxied request no longer counts as local
|
||||
and is asked to authenticate.
|
||||
|
||||
This is not a regression introduced by your values. Upstream removed the
|
||||
hard-coded private networks (prowlarr 2.6.3, radarr 6.4.3; sonarr before
|
||||
4.0.19) because trusting X-Forwarded-For from any private network is a
|
||||
spoofing vector, and this chart follows that default rather than reinstating
|
||||
it behind your back.
|
||||
|
||||
To honour the headers, set the network your ingress controller's pods sit in
|
||||
— the chart cannot know it, as it is a property of the cluster:
|
||||
|
||||
prowlarr.trustedNetworks: "10.42.0.0/16" # e.g. the k3s pod CIDR
|
||||
|
||||
Leaving it empty is a valid choice; it is only not a silent one.
|
||||
{{- end }}
|
||||
|
||||
ACCESS
|
||||
{{- if and $published $ingressApps }}
|
||||
|
||||
@@ -406,6 +406,46 @@ Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
|
||||
{{- $path -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The value for a servarr app's <APP>__SERVER__TRUSTEDNETWORKS variable, or the
|
||||
empty string when the chart must not set it. Same env schema as the UrlBase
|
||||
variable above (<APP>__CONFIGNAMESPACE__CONFIGITEM): verified for all three
|
||||
apps at the tags this chart pins, where Bootstrap.cs binds
|
||||
services.Configure<ServerOptions>(config.GetSection("<App>:Server")) next to a
|
||||
plain .AddEnvironmentVariables().
|
||||
|
||||
The variable is emitted ONLY when the value is non-empty. An empty
|
||||
TRUSTEDNETWORKS would be indistinguishable from an unset one for the app -
|
||||
upstream reads string.Empty either way - but not for a reader of the manifest,
|
||||
to whom it would look like a setting that had been taken care of.
|
||||
|
||||
Empty - i.e. the chart sets nothing - in two cases:
|
||||
|
||||
- the app's trustedNetworks value is empty, which is the DEFAULT. The apps
|
||||
then trust no proxy at all; see the value's comment in values.yaml for what
|
||||
that costs and why it is not a preserved behaviour;
|
||||
- the app's own env block already carries the variable, for the same reason
|
||||
the UrlBase helper above stays out of that case: the env blocks are
|
||||
additive, and two identically named variables in one container are not a
|
||||
defined state to build on.
|
||||
|
||||
Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
|
||||
*/}}
|
||||
{{- define "freesailarr.trustedNetworks" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $app := .app -}}
|
||||
{{- $var := printf "%s__SERVER__TRUSTEDNETWORKS" (upper $app) -}}
|
||||
{{- $value := (index $root.Values $app).trustedNetworks | default "" -}}
|
||||
{{- if $value -}}
|
||||
{{- range $entry := (index $root.Values $app).env -}}
|
||||
{{- if eq (toString $entry.name) $var -}}
|
||||
{{- $value = "" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $value -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app
|
||||
containers (ingress traffic and the kubelet probes both arrive on the pod IP)
|
||||
|
||||
@@ -279,6 +279,14 @@ spec:
|
||||
- name: PROWLARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* Proxy networks whose forwarded headers prowlarr honours.
|
||||
Emitted only when set - empty is the default and means no
|
||||
proxy is trusted. See the freesailarr.trustedNetworks
|
||||
helper and the value's comment in values.yaml. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "prowlarr")) }}
|
||||
- name: PROWLARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 9696
|
||||
@@ -348,6 +356,11 @@ spec:
|
||||
- name: RADARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "radarr")) }}
|
||||
- name: RADARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 7878
|
||||
@@ -424,6 +437,13 @@ spec:
|
||||
- name: SONARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. Sonarr did
|
||||
not lose the trust with this chart version - 4.0.19 was
|
||||
already in the empty state. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "sonarr")) }}
|
||||
- name: SONARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 8989
|
||||
|
||||
@@ -329,6 +329,9 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -395,6 +398,9 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -464,6 +470,9 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -690,6 +699,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"description": "Comma-separated CIDRs whose X-Forwarded-For/-Proto/-Host headers this servarr app honours, rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty. Empty is the default and means no proxy is trusted - the app then reports the ingress controller's pod IP as the client and sees the scheme as http behind TLS termination. Kept null-safe deliberately: an erased value is the same permissive-to-nobody state as the default, so it must not fail the render.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"appStorage": {
|
||||
"description": "Size of this app's config volumeClaimTemplate. Not null-safe on purpose: an erased size yields an invalid claim template and fails loudly.",
|
||||
"type": "object",
|
||||
|
||||
+60
-8
@@ -423,7 +423,7 @@ qbittorrent:
|
||||
enabled: true
|
||||
image:
|
||||
repository: docker.io/linuxserver/qbittorrent
|
||||
tag: 5.2.3
|
||||
tag: 5.2.4
|
||||
# Every app gets its own Ingress object (named <release>-<chart>-<app>-ingress)
|
||||
# with its own TLS secret tls-<release>-<chart>-<app>-ingress, so the apps can
|
||||
# be exposed independently and under unrelated hosts.
|
||||
@@ -628,7 +628,43 @@ prowlarr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: docker.io/linuxserver/prowlarr
|
||||
tag: 2.5.2
|
||||
tag: 2.6.5
|
||||
# Networks whose X-Forwarded-For / -Proto / -Host headers prowlarr honours,
|
||||
# as comma-separated CIDRs (e.g. "10.42.0.0/16"). Rendered as
|
||||
# PROWLARR__SERVER__TRUSTEDNETWORKS, and only when this value is non-empty.
|
||||
#
|
||||
# EMPTY IS THE DEFAULT, AND WITH IT PROWLARR TRUSTS NO PROXY AT ALL. That is
|
||||
# a change in behaviour and not a preserved one. Up to 2.5.2 the app
|
||||
# hard-coded 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 and
|
||||
# fe80::/10 as trusted proxy networks in Startup.cs; 2.6.3 removed all five
|
||||
# and replaced them with this setting, whose own upstream default is
|
||||
# string.Empty - ForwardedHeadersConfigurator returns without registering a
|
||||
# single KnownNetwork while it is empty. Upstream's reason for the change is
|
||||
# that trusting X-Forwarded-For from every private network is a spoofing
|
||||
# vector, and this chart follows that intent rather than papering over it.
|
||||
#
|
||||
# What it costs while this stays empty and the app is published through an
|
||||
# Ingress or an HTTPRoute: the client address prowlarr logs and shows is the
|
||||
# ingress controller's pod IP rather than the real client, and the app sees
|
||||
# the scheme as http even behind TLS termination. If its authentication is
|
||||
# set to "Disabled for Local Addresses", a proxied request no longer counts
|
||||
# as local and is asked to authenticate.
|
||||
#
|
||||
# The chart cannot fill this in for you. The network to trust is the one your
|
||||
# ingress controller's pods sit in - the cluster pod CIDR, or that single
|
||||
# controller pod's IP - which is a property of the cluster and not of the
|
||||
# release. Set it yourself if you want the forwarded headers honoured.
|
||||
#
|
||||
# AllowedHosts, the other key 2.6.3 added, is deliberately NOT exposed as a
|
||||
# value here. Its upstream default is string.Empty as well, and while it is
|
||||
# empty the app accepts requests for any host ("Allowed Hosts is not
|
||||
# configured, accepting requests for any host"), so there is nothing to
|
||||
# repair. One trap to know before anyone sets it through the env block
|
||||
# anyway: the app only ever appends localhost, 127.0.0.1, [::1] and its own
|
||||
# hostname to that list, so neither the pod IP nor the in-cluster Service DNS
|
||||
# name is covered - and the kubelet addresses the probes to the pod IP, so an
|
||||
# incomplete list answers them with HTTP 400.
|
||||
trustedNetworks: ""
|
||||
# See the qbittorrent ingress comment.
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -687,7 +723,7 @@ flaresolverr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/flaresolverr/flaresolverr
|
||||
tag: v3.5.0
|
||||
tag: v3.5.2
|
||||
# No ingress and no config volume: flaresolverr is stateless and only
|
||||
# consumed by prowlarr over localhost.
|
||||
env: []
|
||||
@@ -742,7 +778,13 @@ radarr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: docker.io/linuxserver/radarr
|
||||
tag: 6.3.0
|
||||
tag: 6.4.4
|
||||
# The same setting, the same empty default and the same consequence as
|
||||
# prowlarr's - read the trustedNetworks comment there for the whole picture.
|
||||
# Radarr dropped the five hard-coded private networks in 6.4.3 (6.3.0, the
|
||||
# version this chart shipped before, still had them) and reads this as
|
||||
# RADARR__SERVER__TRUSTEDNETWORKS.
|
||||
trustedNetworks: ""
|
||||
# See the qbittorrent ingress comment.
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -800,7 +842,7 @@ recyclarr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/recyclarr/recyclarr
|
||||
tag: 8.7.1
|
||||
tag: 8.7.3
|
||||
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
|
||||
# sonarr on a schedule (daemon mode).
|
||||
storage:
|
||||
@@ -853,7 +895,17 @@ sonarr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: docker.io/linuxserver/sonarr
|
||||
tag: 4.0.19
|
||||
tag: 4.0.20
|
||||
# The same setting as prowlarr's - read the trustedNetworks comment there.
|
||||
# Reads as SONARR__SERVER__TRUSTEDNETWORKS.
|
||||
#
|
||||
# One difference worth stating plainly: sonarr did NOT lose the forwarded
|
||||
# header trust with this chart version. 4.0.19, the version this chart
|
||||
# shipped before, already had no hard-coded networks and the same
|
||||
# empty-default TrustedNetworks, so the value is being supplied here for a
|
||||
# state that already existed rather than for a new one. It had arrived
|
||||
# unnoticed in what the convention classes as a plain app-version bump.
|
||||
trustedNetworks: ""
|
||||
# See the qbittorrent ingress comment.
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -911,7 +963,7 @@ seerr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/seerr-team/seerr
|
||||
tag: v3.4.1
|
||||
tag: v3.5.0
|
||||
# See the qbittorrent ingress comment.
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -968,7 +1020,7 @@ bazarr:
|
||||
enabled: true
|
||||
image:
|
||||
repository: docker.io/linuxserver/bazarr
|
||||
tag: 1.6.0
|
||||
tag: 1.6.2
|
||||
# See the qbittorrent ingress comment.
|
||||
ingress:
|
||||
enabled: true
|
||||
|
||||
@@ -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
|
||||
type: application
|
||||
|
||||
version: 6.1.1+up2026.8.3
|
||||
version: 6.2.0+up2026.8.3
|
||||
|
||||
appVersion: "2026.8.3"
|
||||
|
||||
|
||||
@@ -18,7 +18,20 @@ print as "1e+06" here just as it did in the rendered manifest.
|
||||
{{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}}
|
||||
{{- $caps := (index $ctr "capabilities") | default dict -}}
|
||||
{{- $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 -}}
|
||||
{{- $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 -}}
|
||||
{{- $tls := $ing.tls | default list -}}
|
||||
{{- $ann := $ing.annotations | default dict -}}
|
||||
@@ -33,7 +46,7 @@ Home Assistant {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
|
||||
|
||||
{{- if $ing.enabled }}
|
||||
|
||||
ACCESS
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
{{- range $h := $hosts }}
|
||||
{{- $scheme := "http" }}
|
||||
{{- range $t := $tls }}
|
||||
@@ -75,11 +88,73 @@ ACCESS
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }}
|
||||
{{- 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 }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress. Nothing else in
|
||||
this chart reads ingress.hosts, so no other setting silently depends on it.
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so this release
|
||||
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
|
||||
|
||||
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
|
||||
localhost:8123 will otherwise mix up the stored tokens.
|
||||
{{- end }}
|
||||
{{- if and $ing.enabled $placeholder }}
|
||||
{{- if and $published $placeholder }}
|
||||
|
||||
THE INGRESS IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
|
||||
ingress.enabled is true while ingress.hosts still contains the default entry
|
||||
"chart-example.local". That name resolves nowhere, so the Ingress exists,
|
||||
ingress-nginx accepts it, and no request ever arrives. Set ingress.hosts to
|
||||
THIS RELEASE IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
|
||||
{{ 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 {{ if $ing.enabled }}Ingress{{ else }}HTTPRoute{{ end }} exists,
|
||||
{{ 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
|
||||
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 }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ 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
|
||||
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
|
||||
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 }}
|
||||
|
||||
BEHIND AN INGRESS, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
|
||||
Every request reaching Home Assistant through ingress-nginx arrives from the
|
||||
controller's pod IP, not from the browser. The http-config field is where
|
||||
that is dealt with, and both ways of getting it wrong are quiet in their own
|
||||
way:
|
||||
BEHIND A PROXY, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
|
||||
Every request reaching Home Assistant through {{ if $httpRoute.enabled }}the Gateway{{ else }}ingress-nginx{{ end }} arrives from
|
||||
{{ if $httpRoute.enabled }}its data-plane pod IP{{ else }}the controller's pod IP{{ end }}, not from the browser. The http-config field is
|
||||
where that is dealt with, and both ways of getting it wrong are quiet in
|
||||
their own way:
|
||||
|
||||
use_x_forwarded_for: true without the proxy's address in trusted_proxies
|
||||
makes Home Assistant reject the request outright — the browser gets
|
||||
400 Bad Request and the log names the untrusted address it saw.
|
||||
|
||||
Neither setting at all means every client looks like the ingress
|
||||
controller. Nothing fails; you simply get one address in the logbook for
|
||||
Neither setting at all means every client looks like {{ if $httpRoute.enabled }}the Gateway{{ else }}the ingress
|
||||
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
|
||||
at once because they share that address.
|
||||
|
||||
The value belongs to the ingress controller's POD network, not to the nodes
|
||||
and not to the clients:
|
||||
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{{ 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
|
||||
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 }}
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -39,20 +39,36 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"tls": {
|
||||
"description": "Not read in Gateway mode: the certificate sits on the Gateway's listener and belongs to the platform.",
|
||||
"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": {
|
||||
"$ref": "#/definitions/securityContext"
|
||||
},
|
||||
@@ -125,6 +141,11 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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"],
|
||||
|
||||
@@ -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/
|
||||
ingress:
|
||||
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
|
||||
# true, and the message names both value paths.
|
||||
enabled: false
|
||||
# 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
|
||||
# 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: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# 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:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
# NOT READ IN GATEWAY MODE: the certificate sits on the Gateway's listener.
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - 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
|
||||
# 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
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ type: application
|
||||
# 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
|
||||
# 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"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -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" .) -}}
|
||||
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
|
||||
{{- $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 -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
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,
|
||||
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" . }}
|
||||
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only
|
||||
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin
|
||||
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
|
||||
{{ else if $httpRoute.enabled }}{{ range $hostnames }} Public: https://{{ . }}
|
||||
{{ end }}{{ else }} Public: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
|
||||
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
|
||||
{{ 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 }}
|
||||
|
||||
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 }}
|
||||
WITHOUT authentication. It has a ClusterIP Service
|
||||
({{ .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 operator materializes one Secret key per item FIELD, so a missing or
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
|
||||
replicas, or the cluster silently becomes two islands.
|
||||
- No deny rule for /admin on the public Ingress, and no restriction of the
|
||||
administration REST API. That is reverse-proxy work - see the first trap.
|
||||
- No deny rule for /admin on the public Ingress{{ if $httpRoute.enabled }} or route{{ end }}, and no restriction of
|
||||
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.
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
|
||||
this file, so switching both Ingresses off frees it.
|
||||
|
||||
BOTH OBJECTS HANG OFF ingress.enabled, AND THE ADMIN ONE DID NOT USED TO
|
||||
(fixed with the Gateway-API support, Gitea #114). ingress.admin.enabled is NOT
|
||||
an Ingress switch: it decides whether KC_HOSTNAME_ADMIN is set on the
|
||||
Deployment, i.e. whether the admin hostname is in play at all - so the admin
|
||||
Ingress used to render with ingress.enabled: false, which meant
|
||||
"ingress.enabled: false" did not mean "no Ingress" and clusterIssuer was still
|
||||
required for an object nobody asked for. Measured before the fix:
|
||||
`helm template -f ci/keycloak/test-values.yaml --set ingress.enabled=false`
|
||||
emitted kc-keycloak-admin-ingress and nothing else of the routing path. It also
|
||||
made Gateway mode impossible: switching httpRoute on leaves ingress.enabled
|
||||
false, and the admin Ingress would have gone on claiming the admin host next to
|
||||
the admin HTTPRoute - one host, two data paths, the exact conflict the mutual
|
||||
exclusion exists to prevent. The default render is unchanged (ingress.enabled
|
||||
defaults to true); what changed is a combination that could not be coherent.
|
||||
*/ -}}
|
||||
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
@@ -61,10 +76,8 @@ spec:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
{{- if $adminEnabled }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if and .Values.ingress.enabled $adminEnabled }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
||||
@@ -187,15 +187,55 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
|
||||
@@ -642,6 +642,11 @@ ingress:
|
||||
|
||||
# Extra annotations for the public Ingress; merged with the cert-manager
|
||||
# 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: {}
|
||||
|
||||
admin:
|
||||
@@ -684,4 +689,84 @@ ingress:
|
||||
# annotation. This is where an IP allow-list would go
|
||||
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
|
||||
# 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: {}
|
||||
|
||||
# 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: []
|
||||
@@ -3,7 +3,7 @@ name: matrix-synapse
|
||||
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
|
||||
type: application
|
||||
version: 7.1.1+upv1.159.0
|
||||
version: 7.2.0+upv1.159.0
|
||||
appVersion: "v1.159.0"
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -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) -}}
|
||||
{{- $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) -}}
|
||||
{{- $res := fromYaml (include "matrix-synapse.effectiveResources" .Values.matrix.resources) -}}
|
||||
{{- $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 }}
|
||||
|
||||
ACCESS
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
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
|
||||
|
||||
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 }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate;
|
||||
ingress.clusterIssuer is not needed.
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so this release has no
|
||||
Ingress, no HTTPRoute and no certificate; ingress.clusterIssuer is not
|
||||
needed.
|
||||
{{- if $delegation.enabled }}
|
||||
ingress.domain still is: the delegation nginx renders it into the
|
||||
/.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"}
|
||||
/.well-known/matrix/client
|
||||
→ {"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,
|
||||
because the chart cannot check it for you:
|
||||
{{ if $httpRoute.enabled }}A SECOND HTTPRoute publishes{{ else }}The Ingress publishes{{ end }} them on a SECOND host. Read that split
|
||||
carefully, because the chart cannot check it for you:
|
||||
|
||||
matrix.delegation.serverUrl ({{ $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
|
||||
above and needs no relation to the server name.
|
||||
|
||||
Only the path /.well-known/matrix is routed to nginx on {{ $delegation.serverUrl }};
|
||||
everything else on that host is not served by this release. Both hosts need
|
||||
Only the path /.well-known/matrix is routed to nginx on {{ join ", " $delegationHostnames }};
|
||||
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
|
||||
cert-manager only issues it once {{ $delegation.serverUrl }} resolves to this cluster.
|
||||
A pending certificate there is a federation outage, not a cosmetic problem.
|
||||
{{- end }}
|
||||
|
||||
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 }}
|
||||
|
||||
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
|
||||
matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is rendered into no object of this
|
||||
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
|
||||
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
|
||||
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
|
||||
to the server name lets this chart serve it.
|
||||
{{- end }}
|
||||
@@ -120,7 +247,11 @@ SCALED TO ZERO
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
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.
|
||||
{{- 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
|
||||
it before the first start — together with matrix.delegation.serverUrl, which
|
||||
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) }}
|
||||
|
||||
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 }}
|
||||
@@ -119,10 +119,11 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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"
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -132,13 +133,53 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"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"],
|
||||
|
||||
@@ -155,10 +155,133 @@ ingress:
|
||||
# read by the delegation nginx, which serves it in the /.well-known/matrix/*
|
||||
# endpoints, so it stays required whenever matrix.delegation.enabled is true.
|
||||
# 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
|
||||
domain: null
|
||||
clusterIssuer: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# 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: {}
|
||||
|
||||
# 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: []
|
||||
+116
@@ -229,6 +229,122 @@
|
||||
// the major bucket once.
|
||||
allowedVersions: "!/^(14\\.3\\.9|20\\.04\\.1)$/",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"The freesailarr stack arrives as ONE grouped PR per cycle instead of one PR per image.",
|
||||
"This is the only groupName in this file and a deliberate reversal of the stance written",
|
||||
"above the app-version managers, which says the built-in helm-values manager finds the ten",
|
||||
"images in that chart's values.yaml and 'proposes one update per image'. That sentence",
|
||||
"described what Renovate does; it was never an argument that one PR per image is what this",
|
||||
"chart wants. For every other chart in this repo it still is — one chart, one image, one",
|
||||
"PR is already the grouping — and that is why this rule is scoped to one file rather than",
|
||||
"turning into a repo-wide policy.",
|
||||
"",
|
||||
"WHAT CHANGED, measured on 2026-10-12 against the Gitea API: eight of this chart's ten",
|
||||
"images had an open Renovate PR at the same moment — #102 bazarr, #103 qbittorrent, #104",
|
||||
"sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr. Each",
|
||||
"one edits freesailarr/values.yaml and, through the postUpgradeTasks hook above, also",
|
||||
"freesailarr/Chart.yaml. The API reported `mergeable: false` for all eight, because the",
|
||||
"`version:` line they rewrite is the same line in all eight. One of them can merge; the",
|
||||
"other seven then need a rebase round each, and every rebase restarts the most expensive",
|
||||
"install-test in this repo — a real Wireguard tunnel and ten containers in one pod.",
|
||||
"",
|
||||
"The second symptom is the sharper one. All eight carried",
|
||||
"`version: 3.1.1+up1.0.0 -> 3.1.2+up1.0.0` while main already stood at 3.3.0+up1.0.0:",
|
||||
"the hook derives everything from the BASE COMMIT (see its comment above), and these",
|
||||
"branches were cut before the Gateway-API rounds landed. Merging one unrebased would have",
|
||||
"moved the chart version backwards by two minors and republished the chart under a version",
|
||||
"the registry already has. A rebase repairs it — the point is that nothing FAILS when the",
|
||||
"rebase does not happen first, so the repair depends on the bot reaching the branch before",
|
||||
"a human reaches the merge button.",
|
||||
"",
|
||||
"WHY GROUPING FIXES BOTH: one branch, one `version:` bump computed once from one base, one",
|
||||
"publish, one install-test run for the whole stack. Nothing is left to serialize, so",
|
||||
"nothing is left to rebase.",
|
||||
"",
|
||||
"MATCHED BY FILE, NOT BY A LIST OF IMAGE NAMES. What creates the collision is 'touches",
|
||||
"freesailarr/Chart.yaml', and that is true of every dependency Renovate finds in that",
|
||||
"chart's values.yaml, whatever its manager, datasource or role in the pod. A",
|
||||
"matchPackageNames list would be a second place to keep the chart's image set current, and",
|
||||
"its failure mode is silence: an image added later simply never joins the group, with no",
|
||||
"error and no warning (#158 — a valid rule is not a matching rule). matchFileNames needs",
|
||||
"no maintenance to stay complete.",
|
||||
"",
|
||||
"Consequences of that choice, each one intended and not a side effect:",
|
||||
" - mediathekarr IS in the group, even though it comes from its own customManagers block",
|
||||
" further down rather than from helm-values. Its digest PR edits the same two files and",
|
||||
" blocks the other seven exactly as they block each other, so leaving it beside the",
|
||||
" group would keep a smaller copy of the problem alive. Its own block is untouched; a",
|
||||
" group decides which BRANCH an update lands on, not how it is found. `digest` is in",
|
||||
" matchUpdateTypes precisely so this one is covered. Verified the way that block's own",
|
||||
" comment demands, because while the pin is current a working rule and a broken one look",
|
||||
" identical: with the real digest the run produces no mediathekarr update at all, so it",
|
||||
" proves nothing; with the digest set to sha256:0000... the digest update appears ON THE",
|
||||
" GROUP BRANCH next to the other eight, not on renovate/docker.io-pcjones-mediathekarr-",
|
||||
" beta as it would without this rule.",
|
||||
" - gluetun and the busybox init image are in the group too, against the instinct to hold",
|
||||
" them apart because gluetun is the tunnel every other container's network namespace",
|
||||
" depends on and busybox is 'only' an init container. Neither property has anything to",
|
||||
" do with the blocking, and a grouped PR hides nothing: the review still sees every",
|
||||
" changed line, it just no longer sees them eight times. (The busybox pinned in",
|
||||
" ci/<chart>/ is a different thing and stays unmanaged — see the ci/** rule at the top.)",
|
||||
"",
|
||||
"MAJORS STAY OUT, which is what matchUpdateTypes is doing here. CLAUDE.md and README",
|
||||
"require an app major to be taken stepwise, in its own PR, and separateMultipleMajor above",
|
||||
"splits a multi-major jump into one PR per major line to produce exactly that sequence. A",
|
||||
"group that threw a major in with seven patches would undo both, and a major is the update",
|
||||
"where the review most needs one image at a time. Majors therefore keep arriving",
|
||||
"individually and keep their deliberately missing age gate.",
|
||||
"",
|
||||
"A groupName ALONE DOES NOT DO THE JOB, which is why the rule directly below exists and",
|
||||
"is not a stylistic split. The global `separateMinorPatch: true` at the top of this file",
|
||||
"keeps splitting minor from patch INSIDE the group. Measured (renovate 44.x,",
|
||||
"--platform=local --dry-run=full, real registries, 2026-10-12, against a checkout at the",
|
||||
"eight pending updates): with the group rule and nothing else, the eight land on TWO",
|
||||
"branches —",
|
||||
" renovate/freesailarr-stack prowlarr 2.6.5, radarr 6.4.4, seerr v3.5.0 (minor)",
|
||||
" renovate/patch-freesailarr-stack bazarr, qbittorrent, sonarr, flaresolverr, recyclarr",
|
||||
"— and note the shape: separateMinorPatch prefixes the branch, it does not suffix it, so",
|
||||
"the second name is easy to miss when scanning a branch list. Two PRs editing the same",
|
||||
"`version:` line is the original problem at a quarter of the size, so the override is",
|
||||
"required for the result this rule is for. With it, the same run puts all eight on one",
|
||||
"branch.",
|
||||
"",
|
||||
"And the override CANNOT live in this rule: renovate rejects the combination outright with",
|
||||
" packageRules[n]: packageRules cannot combine both matchUpdateTypes and",
|
||||
" separateMinorPatch",
|
||||
"which is a config-validation error, not a warning — found by running the dry-run instead",
|
||||
"of trusting the draft. It is consistent, too: separateMinorPatch is what DECIDES the",
|
||||
"updateType split, so a rule may not both perform the split and select on its outcome.",
|
||||
"Hence two rules over the same matchFileNames, one selecting updateTypes to group and one",
|
||||
"setting the split. Keep them together and keep them in this order.",
|
||||
"",
|
||||
"WHAT THIS RULE CANNOT DO: it takes effect on the next run of the shared instance, and",
|
||||
"that instance is outside this repository. What is verifiable from here is that the config",
|
||||
"validates and that the lookup groups as intended; that the bot then behaves that way is",
|
||||
"not provable from this side (see README, 'Renovate on Gitea').",
|
||||
],
|
||||
matchFileNames: ["freesailarr/values.yaml"],
|
||||
matchUpdateTypes: ["patch", "minor", "digest"],
|
||||
groupName: "freesailarr stack",
|
||||
groupSlug: "freesailarr-stack",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"The other half of the freesailarr grouping directly above — read that block first, the",
|
||||
"whole argument is there. Separate only because Renovate refuses matchUpdateTypes and",
|
||||
"separateMinorPatch in one rule ('packageRules cannot combine both matchUpdateTypes and",
|
||||
"separateMinorPatch', a validation error). Without this, the group still arrives as two",
|
||||
"PRs, one of them on the easily missed renovate/patch-freesailarr-stack.",
|
||||
"",
|
||||
"It deliberately carries NO matchUpdateTypes, so it also applies to a major — which costs",
|
||||
"nothing: separateMinorPatch only ever decides whether minor and patch share a branch, and",
|
||||
"a major is in neither bucket. Majors stay single PRs because the rule above does not",
|
||||
"group them, not because of anything here.",
|
||||
],
|
||||
matchFileNames: ["freesailarr/values.yaml"],
|
||||
separateMinorPatch: false,
|
||||
},
|
||||
],
|
||||
|
||||
customManagers: [
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ type: application
|
||||
# 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
|
||||
# exactly the minor case (README, "Versioning").
|
||||
version: 1.1.1+upv0.16.25
|
||||
version: 1.2.0+upv0.16.25
|
||||
appVersion: "v0.16.25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -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" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $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 := "" -}}
|
||||
{{- 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 }}.
|
||||
|
||||
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
|
||||
listeners are already bound.
|
||||
|
||||
4. Configure HTTP forwarding before the auto-ban notices the Ingress.
|
||||
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP
|
||||
4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
|
||||
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
|
||||
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
|
||||
X-Forwarded-For evaluation, and discard forwarding headers the client
|
||||
brought itself. The outage arrives weeks later and looks like a server
|
||||
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.
|
||||
{{- if .Values.certificate.enabled }}
|
||||
@@ -295,7 +317,7 @@ DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
|
||||
{{- end }}
|
||||
{{- 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 }}"
|
||||
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
|
||||
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
|
||||
database (step 3). Between finishing setup and creating that listener the
|
||||
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 }}
|
||||
|
||||
NO INGRESS
|
||||
ingress.enabled is false, so there is no Ingress and no WebUI certificate.
|
||||
NEITHER INGRESS NOR HTTPROUTE
|
||||
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
|
||||
dnsNames, and without it the certificate would carry no name at all.
|
||||
{{- 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
|
||||
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
|
||||
ingress controller's namespace to networkPolicy.allowedNamespaces or the
|
||||
Ingress goes dark.
|
||||
{{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
|
||||
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 }}
|
||||
@@ -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 }}
|
||||
@@ -390,7 +390,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -405,16 +405,34 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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",
|
||||
"minLength": 1
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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"],
|
||||
@@ -455,6 +473,11 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@@ -625,8 +625,137 @@ ingress:
|
||||
nodeGroup: full
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# 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: {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/matszwe02/ytdlp_web_player/main/src/stat
|
||||
|
||||
type: application
|
||||
|
||||
version: 1.0.1+upv1.2.0
|
||||
version: 1.1.0+upv1.2.0
|
||||
appVersion: "v1.2.0"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -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) -}}
|
||||
{{- $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) -}}
|
||||
{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
||||
{{- $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 —
|
||||
this chart's ingress.clusterIssuer does not reach it; set the cert-manager
|
||||
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 }}
|
||||
|
||||
ACCESS
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
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
|
||||
|
||||
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 }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate.
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
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
|
||||
in some other charts in this repo, nothing outside the Ingress template reads
|
||||
the domain: this application builds no absolute URLs and has no base-URL
|
||||
setting. Reach it inside the cluster:
|
||||
in some other charts in this repo, nothing but the two routing templates
|
||||
reads the domain: this application builds no absolute URLs and has no
|
||||
base-URL setting. Reach it inside the cluster:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
@@ -48,14 +110,16 @@ ACCESS — NO INGRESS
|
||||
SCALED TO ZERO
|
||||
{{ 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,
|
||||
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).
|
||||
{{- if $proxy.enabled }}
|
||||
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.
|
||||
{{- 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
|
||||
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
|
||||
@@ -73,22 +137,41 @@ THIS RELEASE IS OPEN TO EVERYONE WHO KNOWS THE URL
|
||||
enabled: true
|
||||
ingress:
|
||||
enabled: true
|
||||
hosts: ["{{ .Values.ingress.domain }}"]
|
||||
hosts: ["{{ $ownHost }}"]
|
||||
ingress:
|
||||
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
|
||||
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 }}
|
||||
{{- if and $proxy.enabled .Values.ingress.enabled }}
|
||||
{{- if and $proxy.enabled $published }}
|
||||
|
||||
WARNING — THE APP'S OWN INGRESS IS A WAY PAST THE PROXY
|
||||
Both oauth2-proxy.enabled and ingress.enabled are true. The chart only
|
||||
WARNING — THE APP'S OWN {{ if $httpRoute.enabled }}ROUTE{{ else }}INGRESS{{ end }} IS A WAY PAST THE PROXY
|
||||
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 —
|
||||
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.
|
||||
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 }}
|
||||
{{- if $proxy.enabled }}
|
||||
|
||||
|
||||
@@ -343,19 +343,72 @@ http://{{ .Release.Name }}-{{ .Chart.Name }}-service:80
|
||||
{{- 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
|
||||
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
|
||||
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.
|
||||
Failing here costs a re-render.
|
||||
ytdlp-web-player.published = ingress.enabled OR httpRoute.enabled
|
||||
|
||||
Added with the Gateway-API support (Gitea #114), and it is the same shape as
|
||||
freesailarr.published and sure.publishedOverTls. It exists because
|
||||
httpRoute.enabled is an ALTERNATIVE to ingress.enabled, so in Gateway mode
|
||||
ingress.enabled is false BY CONSTRUCTION - and every reader of
|
||||
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
|
||||
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
|
||||
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" -}}
|
||||
{{- $proxy := (index .Values "oauth2-proxy") | default dict -}}
|
||||
{{- $proxyIngress := $proxy.ingress | default dict -}}
|
||||
{{- if and .Values.ingress.enabled $proxy.enabled $proxyIngress.enabled -}}
|
||||
{{- if has .Values.ingress.domain ($proxyIngress.hosts | default list) -}}
|
||||
{{- 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) -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and (eq (include "ytdlp-web-player.published" .) "true") $proxy.enabled $proxyIngress.enabled -}}
|
||||
{{- /*
|
||||
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 -}}
|
||||
@@ -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 }}
|
||||
@@ -72,30 +72,54 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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"],
|
||||
|
||||
@@ -232,11 +232,18 @@ storage:
|
||||
cacheSizeLimit: null
|
||||
|
||||
ingress:
|
||||
# Gates this chart's own Ingress. Everything the Ingress needs is read only
|
||||
# inside its template - the application itself needs no hostname of its own
|
||||
# (no base-URL setting, no absolute links, no url_for(_external=True)), so
|
||||
# switching the Ingress off frees BOTH ingress.domain and
|
||||
# ingress.clusterIssuer instead of only the issuer.
|
||||
# Gates this chart's own Ingress. MUTUALLY EXCLUSIVE WITH httpRoute.enabled -
|
||||
# the render aborts when both are true, and the message names both value
|
||||
# paths.
|
||||
#
|
||||
# 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,
|
||||
# so with oauth2-proxy.enabled=true the Ingress belongs to the PROXY
|
||||
@@ -252,8 +259,89 @@ ingress:
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# 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: {}
|
||||
|
||||
# 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 -------------------------------------------------
|
||||
#
|
||||
# Everything below this line is passed to the oauth2-proxy chart
|
||||
|
||||
Reference in new issue
Block a user