diff --git a/CI-CD.md b/CI-CD.md index ccfd7d1..367471b 100644 --- a/CI-CD.md +++ b/CI-CD.md @@ -154,13 +154,19 @@ Three things to know before touching it: - **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that, a run in which **no assertion at all** held fails. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode); every other converted chart through its overlay. - **For a multi-app chart the overlay has a second job**: it must enter **every** shape the template has, or the branch nobody exercised is the one that breaks. `ci/httproute/freesailarr.yaml` moves one app onto a host of its own on top of the fixture's shared host, so the shared-host route *and* a per-app route both render — and it leaves `mediathekarr` published-off, which exercises the per-app selector in the negative direction as well. It also keeps qBittorrent **on** the shared host, which is what makes the prefix/redirect pairing fire for a real chart rather than cover nothing. - **And a multi-object overlay has to enter every object**, which group 3 made routine: `ci/httproute/keycloak.yaml` covers the public *and* the admin route (the admin parent falling back to the chart-global one, which is the branch an installation takes), and `ci/httproute/matrix-synapse.yaml` covers the homeserver *and* the delegation route — two hostnames, two paths and two **different** backends, which is the only overlay in the repo where the two `backendRefs` resolve to different Services. -- **One overlay has to *supply* hostnames**, and only one: `ci/httproute/homeassistant.yaml`. That chart has no `ingress.domain`, so its hostnames come from `ingress.hosts`, whose default is the scaffold placeholder — one host with one root path. The overlay therefore sets two entries, the second with **two** paths including an `Exact` one, because that second entry is what proves the chart renders **one object per host entry** rather than one object with several hostnames. For that chart the overlay also **adds** coverage rather than trading it: `ingress.enabled` defaults to `false` there and the fixture does not switch it on, so the install-test had never exercised a routing object at all. Same situation as [garage](garage). +- **Two overlays have to *supply* hostnames**, and for two different reasons. `ci/httproute/homeassistant.yaml`: that chart has no `ingress.domain`, so its hostnames come from `ingress.hosts`, whose default is the scaffold placeholder — one host with one root path. The overlay therefore sets two entries, the second with **two** paths including an `Exact` one, because that second entry is what proves the chart renders **one object per host entry** rather than one object with several hostnames. For that chart the overlay also **adds** coverage rather than trading it: `ingress.enabled` defaults to `false` there and the fixture does not switch it on, so the install-test had never exercised a routing object at all. Same situation as [garage](garage). `ci/httproute/rss-bridge.yaml`: that chart *has* a `httpRoute.hostnames` key, but it is **mandatory with no fallback** — no `ingress.domain` to fall back to, and it deliberately does not reuse `ingress.hosts`, whose entries carry `tlsSecretName` values that address nothing in Gateway mode. Its overlay sets **three** hostnames on **one** object, which is the shape its production installation has and the opposite of homeassistant's one-object-per-host. For that chart the overlay is the only automated coverage its Gateway mode has, because its install-test installs the Ingress mode — the ordinary situation across the sweep. See [rss-bridge](rss-bridge). - **A render-*failure* path cannot live here**, and two of group 3's guards are exactly that. This test only runs renders it expects to succeed, so homeassistant's `pathType: ImplementationSpecific` abort on a non-root path, and ytdlp-web-player's refusal to publish the app and its oauth2-proxy on one host, were exercised by hand instead. Where a guard matters, the chart's page says so and the PR records the exercise — a check that would have to assert a failing render belongs in the chart's own `fail`, not here. - **What the overlays do *not* cover is the text of `NOTES.txt`.** The Gateway branch of a chart's notes is *executed* by this test (a template error in it fails the job — verified by breaking one on purpose), but `helm template` does not print notes and the install-test installs in Ingress mode. So the notes' Gateway wording is reviewed by reading, never by a check. 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 of the sweep took that to 20 assertions over 10 charts and group 2 to 36 over 15 — the sweep adds charts, not checks. The prefix/redirect pairing of Gitea #118 is the first *check* added since the pilot rather than a chart, which brought it to 37 over 15: one assertion for freesailarr's `/qbittorrent`, plus one further "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`. Its 19 come from six charts, and three of them contribute more than one route: `keycloak` **4** (public + admin), `matrix-synapse` **4** (homeserver + delegation, two different backends), `homeassistant` **5** (two routes, three rules), `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. +Group 3 took it to `56` assertions over `21` charts. Its 19 come from six charts, and three of them contribute more than one route: `keycloak` **4** (public + admin), `matrix-synapse` **4** (homeserver + delegation, two different backends), `homeassistant` **5** (two routes, three rules), `bulwark` **2**, `stalwart` **2**, `ytdlp-web-player` **2**. + +**`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — one route, so two assertions. It was the last chart left: 22 charts carried an Ingress template and 21 an HTTPRoute template, and the difference was exactly that one, held back by Gitea #51 rather than by anything about its shape. + +**That chart's install-test also went green in the same PR**, after failing on this runner since the move — the event #51 had designated as its own signal, and the reason the chart was deliberately never added to `ci/excluded-charts.txt`. Pod `1/1 Ready` in 22 s, 0 restarts, no `Permission denied` in the job. The chart is ruled out as the cause by measurement rather than by argument: that PR's rendered defaults are **byte-identical** to `origin/main`, so the pod spec that installed is the one that had been failing. The runner is the implicated layer. It is **one** run, though, and nobody has identified what changed there — so the issue is not closed on the strength of a colour, and a fresh red would be the control case this result is missing. See [rss-bridge](rss-bridge). + +The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit. ### 6. `trivy-config` (reporting only) @@ -237,7 +243,7 @@ A line in `ci/excluded-charts.txt` carrying only a chart name is deliberately ** The install-test matrix follows the charts a pull request **changes**. So there is no moment at which the charts are checked as a whole against the current environment — and a chart nobody has touched since the forge move carries a green history from a *different runner*. That green says nothing about today, which is exactly the signal [rule 2](#two-rules-for-every-check-added-here) is about. -The value of the answer was demonstrated by accident. Gitea PR #48 corrected a **comment** in 21 charts, which made every one of them "changed" and turned the run into the first all-charts install-test after the move: **19 of 20 matrix charts installed and became Ready in 12m41s**, in one job, on the Gitea runner with the pinned `kubectl` inside the job container — and exactly one did not. That one was `rss-bridge`, whose last green run was from **2026-08-29 and a GitHub runner** (Gitea #51). Without the comment round, nobody would have learned either half. +The value of the answer was demonstrated by accident. Gitea PR #48 corrected a **comment** in 21 charts, which made every one of them "changed" and turned the run into the first all-charts install-test after the move: **19 of 20 matrix charts installed and became Ready in 12m41s**, in one job, on the Gitea runner with the pinned `kubectl` inside the job container — and exactly one did not. That one was `rss-bridge`, whose last green run at the time was from **2026-08-29 and a GitHub runner** (Gitea #51). Without the comment round, nobody would have learned either half. That chart has since gone green on the Gitea runner too — on 2026-10-13, in a PR whose rendered defaults were byte-identical to `main`, which is what rules the chart out and leaves the runner. ### When to run it diff --git a/Lessons-Learned.md b/Lessons-Learned.md index 181ed10..e968ae8 100644 --- a/Lessons-Learned.md +++ b/Lessons-Learned.md @@ -671,3 +671,53 @@ prowlarr 2.6.3 and radarr 6.4.3 removed all five and replaced them with a `Trust **What the chart does about it, and what it deliberately does not.** A `.trustedNetworks` value was added for all three apps — sonarr included, because repairing two and knowingly leaving the third is the same silent pattern — rendered as `__SERVER__TRUSTEDNETWORKS` and **only when non-empty**, since an empty variable is indistinguishable from an unset one to the app but looks handled to a reader of the manifest. Its default is empty, so **the chart does not restore the old behaviour**: it follows upstream's intent and makes the state visible, with a `NOTES.txt` warning that fires in exactly the combination that costs something — the app published through an Ingress *or* an HTTPRoute, and the value empty. `AllowedHosts`, added by the same upstream commits, is deliberately not exposed: its default is `string.Empty` too and the app then accepts any host, so there is nothing to repair. If anyone ever sets it, the trap to know is that the app appends only `localhost`, `127.0.0.1`, `[::1]` and its own hostname, so neither the pod IP nor the Service DNS name is covered — and the kubelet probes the pod *by IP*, which an incomplete list answers with HTTP 400. **Left open on purpose:** whether the convention that exempts a plain app-version bump from the wiki obligation should change. It is the mechanism that let this through, and it is a decision of its own rather than an appendix to a version PR. + +## The claim four pull requests refused to make, and the measurement that finally reached it — one layer further out than wanted + +The Gateway-API sweep (Gitea #114) ran as a pilot plus three groups, and **every one of those pull requests says in as many words that "it routes" is not demonstrable.** No Gateway controller runs in CI, no `HTTPRoute` ever reaches `Accepted: True` there, and what the pipeline proves is a schema-valid object against the real CRDs plus the render assertions. That discipline was right, and it was also uncomfortable: 22 charts gained a routing mode that nothing had ever exercised end to end. + +On 2026-10-13 `freesailarr.jk-dev.de` was switched to Gateway mode, and the claim became measurable. What came back, read-only: + +- **No `freesailarr` Ingress object exists, in any namespace**, while the host answers. +- `GET /radarr` → `302`, `server: Kestrel`, `location: …/radarr/login?returnUrl=%2Fradarr`. The app knows its own base path, so the `freesailarr.published` helper is working **in production** — without it `RADARR__SERVER__URLBASE` would be empty in this mode and the UI broken behind a `Ready` pod. +- `GET /qbittorrent` → `301` to the slashed form, 17-byte body, no `content-type`, no HSTS. The redirect the Ingress mode *cannot* render is being served. +- `GET /qbittorrent?foo=bar` → `location: …/qbittorrent/?foo=bar`. The query string survives. + +### The lesson is not "it works" — it is where the measurement stopped + +Three limits, and each one is the interesting part: + +1. **The implementation cannot be named.** `httproutes` and `gatewayclasses` are RBAC-forbidden to the read-only identity. That the 301 comes from the chart's rule rather than from the operator's nginx snippet is **inferred** — from the snippet's carrier being gone (`ingress.annotations` are dropped in this mode) and from the response signature changing with it (before: nginx, `text/html`, 162 bytes, HSTS). A good inference is still an inference, and it was written down as one. +2. **There is no `freesailarr` namespace in the cluster at all**, and no pods of it visible, yet the host answers. So "the host is served without an Ingress" is solid; "a particular `HTTPRoute` of this chart serves it" is not readable from here. +3. **Every HTTP measurement passed through a front proxy.** The hosts resolve to a single address that is not the ingress/gateway load balancer. So the response signature is evidence about what the proxy returned, which is one hop removed from what the Gateway did. + +**The generalisable point:** a measurement that reaches the system is worth far more than a render — and it still has a boundary, which is usually a layer of infrastructure nobody mentioned. The honest form is to state what was observed, what was inferred from it, and which layer the observation actually came from. "It routes in production" would have been an overstatement of a true result; "the host is served, without any Ingress object, with the base paths and the redirect intact, measured through the front proxy, implementation unnameable" is the same news without the debt. + +### And the query-string answer is a data point, not a promise + +The specification is unchanged: `HTTPRequestRedirectFilter` has `scheme`, `hostname`, `path`, `port` and `statusCode`, and **no query field**. So the chart still cannot promise the query survives a redirect, and the documentation still says so. What changed is that for this implementation, on this day, it demonstrably does. Both halves belong in the page — a measured behaviour that the specification does not guarantee is exactly the kind of thing that gets remembered as a guarantee, and then relied on by a cluster running something else. + +## A screen that finds nothing is still a result worth writing down + +The sweep's `ingress.enabled`-derived-value screen found three real defects — `sure`'s TLS flags, `freesailarr`'s sub-path variables, `ytdlp-web-player`'s authentication bypass — each of them a value computed from a switch that Gateway mode flips by construction, wrong while nothing fails. + +Run over `rss-bridge`, the last chart, it came back **clean**: outside the Ingress template the only readers of an ingress value are `NOTES.txt`, which knows its own mode, and a TLS-grouping helper called only from behind the switch. The application takes no base URL and no hostname in its environment, so there was nothing to compute and no helper to add. + +That result was recorded in the README and on the chart's page anyway, and the reason is practical rather than tidy: **an unrecorded clean screen is indistinguishable from a screen nobody ran.** The next person to wonder whether this chart has the defect its three siblings had would otherwise have to redo the work — and the cheaper path, deciding it "probably doesn't", is exactly how the `ytdlp-web-player` bypass survived two earlier rounds of the same sweep. The value of a screen is that it was run; that fact is only available if the negative is written down too. + +## The red check that was kept red so it could say something — and then said it + +`rss-bridge`'s install-test had failed on the Gitea runner since the move: php-fpm as uid 33 unable to read `/app/index.php`, the startup probe 403ing through its whole 150 s budget, the pod never `Ready`. Everything else about the chart was healthy — the same chart and the same image ran in production with `Ready=True` and 0 restarts, the runner pulled the same image digest Docker Hub serves, and `/app/index.php` is mode `0644` in a `0755` directory, i.e. world-readable. 23 of 24 charts came up on that same runner. The defect was localised to the environment and was not reproducible anywhere else. + +An exclusion in `ci/excluded-charts.txt` was proposed and **rejected**. The argument: an exclusion makes the pipeline honestly green and simultaneously destroys the only instrument pointed at the problem. A skipped test says nothing — not while the fault persists, and *especially* not once it is gone. + +So the chart stayed in the matrix and the check stayed red, explicitly as the status indicator for the issue. + +**On 2026-10-13 it went green.** Pod `1/1 Ready` in 22 s, 0 restarts, no permission error in the job. And the run that produced it happened to carry the strongest possible control: the PR's rendered output for the fixture values was **byte-identical to `origin/main`**, proven as part of justifying a Minor version bump. So the pod spec that succeeded was, line for line, the pod spec that had been failing. The chart was ruled out not by reasoning but by a diff. + +### What generalises + +- **An excluded test is not a quiet test, it is a blind one.** The cost of an exclusion is usually discussed as "we lose coverage while the bug exists". The larger cost is that you also lose the moment the bug stops existing — and nobody schedules a day to go and re-check a skipped test. +- **Keep a known failure attached to the thing it is about.** Red-and-explained is a worse pipeline and a better instrument than green-and-skipped. That trade is only correct when the explanation is written down where the red will be seen, which is why the chart's page and the issue both carried it. +- **A green that arrives on an unchanged input is worth more than a green that arrives with a fix**, because it localises the cause by elimination. Here the byte-identical render was produced for an unrelated reason — version-bump justification — and turned out to be the control case. Cheap evidence collected as a matter of routine is what makes an accident interpretable. +- **One run is not a repair.** The colour changed; nobody has yet identified what changed on the runner. An intermittent effect needs several runs per side, so the issue stays open and a fresh red would be the missing control rather than a surprise. **Close an issue on a cause, not on a colour** — which was the standing instruction on this one before the green arrived, written precisely so that the green could not be mistaken for the answer. diff --git a/freesailarr.md b/freesailarr.md index 273a844..583d2e7 100644 --- a/freesailarr.md +++ b/freesailarr.md @@ -278,6 +278,34 @@ kubectl -n get httproute -o yaml `Accepted: True` and `ResolvedRefs: True` under `status.parents[]` is a working attachment. An **empty** status means no controller has looked at the route — usually a `parentRefs` pointing at a Gateway that does not exist, or a listener whose `allowedRoutes` does not admit this namespace. +### The Gateway mode is in production + +Every PR of the Gateway-API sweep (Gitea #114) says in as many words that "it routes" is **not** demonstrable in CI: no Gateway controller runs there, no `HTTPRoute` ever reaches `Accepted: True`, and what is proven is a schema-valid object plus the render assertions. `freesailarr` is where that changed. On 2026-10-13 `freesailarr.jk-dev.de` was switched to Gateway mode, and these are the first measurements of this repository's Gateway mode against a running system. + +**Measured** (read-only, no redirects followed, 2026-10-13): + +| | | +|---|---| +| `freesailarr` Ingress objects | **none**, in any namespace. Cross-checked: the same identity lists 10 Ingress objects across six other namespaces, so this is a real absence and not a visibility gap | +| `GET /radarr` | `302`, `server: Kestrel`, `location: https://freesailarr.jk-dev.de/radarr/login?returnUrl=%2Fradarr` | +| `GET /qbittorrent` | `301`, `location: …/qbittorrent/`, 17-byte body, no `content-type`, no HSTS, no `server` | +| `GET /qbittorrent?foo=bar` | `301`, `location: …/qbittorrent/?foo=bar` — the query string survives | + +**What each one settles.** + +The `/radarr` answer is radarr itself, and `returnUrl=%2Fradarr` proves it knows its own base path. So the `freesailarr.published` helper is doing its job **in production**: without it `RADARR__SERVER__URLBASE` would be empty in Gateway mode — `ingress.enabled` is `false` there by construction — and the UI would be broken behind a `Ready` pod, assets and API calls linked at the host root. That is exactly the defect the helper was written for, and it was measured rather than asserted. + +The two qBittorrent answers show the chart's own redirect rule working, and the query string surviving it. The specification still does not promise the latter — see [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix). + +**A consequence for the operator:** the nginx snippet in `jk-cluster-config` is now **without effect and can be removed**. Removing it earlier would have been wrong — in Ingress mode it was the only thing performing the redirect. It lost its effect not through a change in the chart but because the mode drops `ingress.annotations`. + +**The limits of these measurements, which are real.** None of this names the implementation, and two independent reasons prevent it: + +- `httproutes` and `gatewayclasses` are **RBAC-forbidden** to the read-only identity (`system:serviceaccount:coder-workspaces:claude-readonly`), so the route and its controller cannot be read at all. That the 301 comes from the chart's rule is inferred from the carrier's disappearance plus the changed response signature, not read off an object. +- There is **no `freesailarr` namespace in `jk-cluster`** and no pods of it visible, while the host answers — and both `freesailarr.jk-dev.de` and the other hosts of this cluster resolve to a single front proxy (`proxy.jk-dev.de`). So every HTTP measurement above passed through that proxy before reaching anything, and where the backend runs is not determinable from here. + +What is therefore established: the host is served, it is served **without any Ingress object**, the sub-path apps know their base paths, and the bare-prefix redirect happens with the query intact. What is **not** established: which Gateway implementation does it, or that any particular `HTTPRoute` of this chart is the object doing it. The first proof that this repository's Gateway mode routes at all is still a proof about the *form*, obtained one layer further out than one would like. + ### Two routing shapes, and they mix 1. **One host per app** (default): each app with `.ingress.enabled` gets its own object — `-freesailarr--ingress` in Ingress mode, `-freesailarr--httproute` in Gateway mode — its own host and, in Ingress mode, its own certificate. The hosts need not share a domain. @@ -302,7 +330,7 @@ A request for `https:///qbittorrent` — no trailing slash — is stripped - **Precedence beats position.** The Gateway API ranks `Exact` path match above `Prefix path match with largest number of characters`, and consults list order only when every earlier criterion ties. The rule therefore wins over qBittorrent's own `PathPrefix` rule *and* over seerr's `PathPrefix /` wherever it is emitted. The rules' length-descending order in the rendered route stays what it always was: a reviewable rendering convention, not matching behaviour. - **301, not 308.** Only 301 and 302 are **Core** in that API; everything else is **Extended**, and an implementation that does not support the value must set `Accepted: False` with reason `UnsupportedValue` on the **whole route**. On the shared host that is one route carrying every app, so an Extended status code one implementation declines takes prowlarr, radarr, sonarr and seerr down with qBittorrent — schema-valid, in a status field nothing in CI writes. It would also break the implementation-neutral subset rule of the Gateway-API convention. 308's method preservation is not needed here: the rule fires only on the prefixless UI entry path, which a browser reaches with GET, while the API lives under `/api/…` and is served by the `PathPrefix` rule. `statusCode` is written out in the template because it **defaults to 302**. -- **The query string is the one asymmetry.** `HTTPRequestRedirectFilter` has exactly five fields (`scheme`, `hostname`, `path`, `port`, `statusCode`) and says nothing about the query; `ReplaceFullPath` replaces the full *path*. The nginx remedy below carries the query over explicitly with `$is_args$args`. It costs nothing on the UI entry path, which takes no query parameters, but it is a real difference and not a translation. +- **The query string is the one asymmetry — and it has since been measured, though not promised.** `HTTPRequestRedirectFilter` has exactly five fields (`scheme`, `hostname`, `path`, `port`, `statusCode`) and says nothing about the query; `ReplaceFullPath` replaces the full *path*. The nginx remedy below carries the query over explicitly with `$is_args$args`. **The specification is still silent, so this chart still cannot promise the query survives.** What exists now is a data point: measured against the live host on 2026-10-13, `GET /qbittorrent?foo=bar` answered `301` with `location: https://freesailarr.jk-dev.de/qbittorrent/?foo=bar` — the query string **was** preserved. That settles the question for *this* implementation on that day and nothing more; it is not a guarantee, and a different Gateway implementation may legitimately drop it. It costs nothing on the UI entry path, which takes no query parameters, so the asymmetry remains real on paper and has so far not been observable in practice. **In Ingress mode the chart renders no redirect, and the remedy is the operator's to install.** It is an nginx snippet, supplied through the global `ingress.annotations` (which are merged into both of the chart's objects on the shared host, which is what makes it work from there): @@ -321,6 +349,16 @@ That short-circuit alone settles it. What an unanchored regex location would the **What CI can and cannot prove about this.** `ci/run-httproute-match-test.sh` asserts, per `HTTPRoute` object, that a rule stripping a prefix with `ReplacePrefixMatch` is accompanied by a rule matching that path `Exact`ly with a `RequestRedirect` filter — see [CI/CD](CI-CD). That proves the rendered **object** carries the redirect. It does **not** prove a 301 is ever served: no Gateway controller runs in CI, no route reaches `Accepted: True`, and nothing there evaluates matching precedence. +**And what production now shows, which CI could not.** Measured against the live host on 2026-10-13, no redirects followed: + +``` +GET https://freesailarr.jk-dev.de/qbittorrent +→ 301, location: https://freesailarr.jk-dev.de/qbittorrent/ + content-length: 17, no content-type, no strict-transport-security, no server header +``` + +A 301 **is** served, and the rule fires. That the 301 comes from *this chart's rule* rather than from the operator's nginx snippet is **inferred, not read**: the snippet's carrier is gone (`ingress.annotations` are dropped in Gateway mode, and there are no `freesailarr` Ingress objects left), and the response signature changed with it — before: nginx, `content-type: text/html`, 162 bytes, HSTS; now: 17 bytes, none of those three headers. The implementation itself cannot be named from here, because `httproutes` and `gatewayclasses` are RBAC-forbidden to the read-only identity. See [The Gateway mode is in production](#the-gateway-mode-is-in-production) for what else that measurement round established, and for its limits. + ### Forwarded headers are not trusted by default `prowlarr`, `radarr` and `sonarr` trust **no** proxy out of the box. Their `trustedNetworks` value is empty by default, and while it is empty the app registers not a single trusted network — upstream's `ForwardedHeadersConfigurator.Configure` returns early. diff --git a/rss-bridge.md b/rss-bridge.md index 74dadd6..d08102d 100644 --- a/rss-bridge.md +++ b/rss-bridge.md @@ -1,6 +1,8 @@ # rss-bridge -Deploys [RSS-Bridge](https://github.com/RSS-Bridge/rss-bridge), which turns websites without a feed into RSS/Atom, as a stateless Deployment. The enabled bridges come from a ConfigMap rendered as `whitelist.txt`; the feed cache is an emptyDir and is meant to be lost. +Deploys [RSS-Bridge](https://github.com/RSS-Bridge/rss-bridge), which turns websites without a feed into RSS/Atom, as a stateless Deployment behind an Ingress **or** a Gateway-API `HTTPRoute`. The enabled bridges come from a ConfigMap rendered as `whitelist.txt`; the feed cache is an emptyDir and is meant to be lost. + +Two things about this chart are unlike any other in the repository, and both are worth knowing before reading on: it was the **last chart of the Gateway-API sweep** (Gitea #114), and its install-test had been **failing on the Gitea runner since the move** for a reason that lies in the runner and not in the chart (Gitea #51) — until it **passed** in the Gateway-API conversion PR, on a render byte-identical to `main`. See [The install-test, which was red on purpose and then went green](#the-install-test-which-was-red-on-purpose-and-then-went-green). | | | |---|---| @@ -8,18 +10,29 @@ Deploys [RSS-Bridge](https://github.com/RSS-Bridge/rss-bridge), which turns webs | Versions | not listed on purpose — read the current pair off `rss-bridge/Chart.yaml`, see [Chart index](Charts) | | Workloads | Deployment (1 replica) | | Storage | none — five emptyDirs, feed cache included | -| Ingress | yes, `ingress.hosts` (a **list**) + `ingress.clusterIssuer` required while enabled | +| Routing | **two mutually exclusive modes.** Ingress (default): `ingress.hosts` (a **list**) + `ingress.clusterIssuer` required while `ingress.enabled`. Gateway API: `httpRoute.enabled` with `httpRoute.parentRefs` **and** `httpRoute.hostnames`, both mandatory in that mode — see [Routing modes](#routing-modes) | | External dependencies | none in the cluster; the bridges fetch the sites they scrape | | Secrets | none — no `OnePasswordItem` | -| Install-test | `ci/rss-bridge/` (test values only, no fixtures) | +| Install-test | `ci/rss-bridge/` (test values only, no fixtures). Installs the **Ingress** mode; red on the runner from the move until 2026-10-13, see Gitea #51. The Gateway mode is covered by a render overlay at `ci/httproute/rss-bridge.yaml` | The image reference is written out with its registry on purpose: an unqualified Docker Hub reference puts `rssbridge` in the registry position, which says nothing about where the image comes from and is reported as an untrusted registry (AVD-KSV-0125). ## Prerequisites +**In Ingress mode (the default):** + - **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for every entry of `ingress.hosts` pointing at the ingress controller. - **Where existing certificates are being taken over**, know their secret names before installing — see [Several hosts, several certificates](#several-hosts-several-certificates). -- Nothing else. No database, no cache service, no 1Password item, no volume. + +**In Gateway mode (`httpRoute.enabled: true`):** + +- The **Gateway-API CRDs**, standard channel. The chart does not and cannot detect them, so without them the install fails with `no matches for kind HTTPRoute`. +- A **Gateway** whose listener accepts routes from this namespace (`allowedRoutes`), named in `httpRoute.parentRefs`. The chart renders no Gateway. +- A **certificate on that Gateway's listener, for every hostname**. `ingress.clusterIssuer` is unused and unnecessary here — the chart renders no `Certificate` in this mode, and none of the `tls-*` secrets of the Ingress mode exists. +- `httpRoute.hostnames` set explicitly. Unlike most charts in this repo there is **no fallback**: this chart has no `ingress.domain`, and it does not reuse `ingress.hosts` — see [Why `httpRoute.hostnames` is its own value](#why-httproutehostnames-is-its-own-value). +- DNS for every name in `httpRoute.hostnames` pointing at the Gateway. + +**Either way:** nothing else. No database, no cache service, no 1Password item, no volume. ## Installation @@ -43,7 +56,22 @@ ingress: - host: rss-bridge.example.com ``` -`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — the hosts, the TLS secrets they were grouped into and the enabled bridges. Read it — it is the per-release version of this page. +The same installation in Gateway mode: + +```yaml +ingress: + enabled: false # not optional - the default is true and the modes are exclusive +httpRoute: + enabled: true + parentRefs: + - name: public + namespace: gateway + sectionName: https + hostnames: # mandatory: no fallback in this chart + - rss-bridge.example.com +``` + +`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — which mode is active, the hostnames, the TLS secrets they were grouped into (Ingress mode) or what the platform owes the release (Gateway mode), and the enabled bridges. Read it — it is the per-release version of this page. ## Configurable values @@ -78,18 +106,86 @@ Probes, security contexts and resources are null-safe: an erased block means "ch | Key | Type | Default | Description | |---|---|---|---| -| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Nothing outside it reads an ingress value — the container takes no host in its environment and nginx serves as `default_server` without a `server_name` — so switching it off frees every value below, `hosts` included. | -| `ingress.hosts` | list of objects | `null` | **Required while `ingress.enabled` is true.** The hosts this Ingress serves. The chart ships no default, because hostnames are installation data. See [Several hosts, several certificates](#several-hosts-several-certificates). | +| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Nothing outside it reads an ingress value — the container takes no host in its environment and nginx serves as `default_server` without a `server_name` — so switching it off frees every value below, `hosts` included. **Mutually exclusive with `httpRoute.enabled`**: with both `true` the render aborts naming both values. The default is `true`, so Gateway mode needs this set to `false` explicitly. | +| `ingress.hosts` | list of objects | `null` | **Required while `ingress.enabled` is true.** The hosts this Ingress serves. The chart ships no default, because hostnames are installation data. **Not read in Gateway mode** — the names come from `httpRoute.hostnames` there. See [Several hosts, several certificates](#several-hosts-several-certificates). | | `ingress.hosts[].host` | string | — | **Required** per entry. The hostname. | -| `ingress.hosts[].tlsSecretName` | string | `null` | Overrides the conventional name for this host. Omitted, the entry falls back to `tls--rss-bridge-ingress`. Set it only where an existing certificate must keep its secret name. | -| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`; it issues every secret in the grouping. | -| `ingress.className` | string | `nginx` | Ingress class. | -| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | +| `ingress.hosts[].tlsSecretName` | string | `null` | Overrides the conventional name for this host. Omitted, the entry falls back to `tls--rss-bridge-ingress`. Set it only where an existing certificate must keep its secret name. **Meaningless in Gateway mode**, where the certificate sits on the listener — which is exactly why `httpRoute.hostnames` does not reuse this list. | +| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`; it issues every secret in the grouping. **Not read in Gateway mode** — the chart renders no `Certificate` there. | +| `ingress.className` | string | `nginx` | Ingress class. **Not read in Gateway mode** — the class is a property of the Gateway, which belongs to the platform. | +| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so anything set here is gone in that mode. For this chart that costs nothing today — see [Nothing is lost in Gateway mode](#nothing-is-lost-in-gateway-mode-and-that-is-measured). | + +### `httpRoute` + +The Gateway-API alternative, off by default. `rss-bridge` was the **last** chart to receive it (Gitea #114); the form is binding for all charts ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)). + +| Key | Type | Default | Description | +|---|---|---|---| +| `httpRoute.enabled` | bool | `false` | Renders `-rss-bridge-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`**, whose default is `true`. | +| `httpRoute.parentRefs` | list | `[]` | The Gateway(s) the route attaches to — Gateway API `ParentReference` entries (`name`, and optionally `namespace`, `sectionName`, `port`, `group`, `kind`), passed through unchanged. **Required while `httpRoute.enabled` is true**: the render aborts on an empty list. The schema requires `name` on every entry and is otherwise left open, because Kubernetes owns that schema. | +| `httpRoute.hostnames` | list of strings | `[]` | The public names of this release. **Required while `httpRoute.enabled` is true** — the render aborts on an empty list, and there is **no fallback**. One `HTTPRoute` carries all of them. See [Why `httpRoute.hostnames` is its own value](#why-httproutehostnames-is-its-own-value). | + +Like every other block this chart dereferences, `httpRoute` is null-safe: an erased `httpRoute:` is YAML null and means "chart defaults apply", which collapses to "no `HTTPRoute`" rather than aborting with a nil pointer. ## Operating notes +### Routing modes + +Exactly one of `ingress.enabled` and `httpRoute.enabled` may be true. Both true aborts the render, and the message names both value paths. + +| Guard | Why it is a hard stop | +|---|---| +| `ingress.enabled` and `httpRoute.enabled` cannot both be true | two data paths onto one Service under the same hostnames. That is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. **A migration runs over a second hostname** — give `httpRoute.hostnames` a name the Ingress does not serve — never in parallel. | +| `httpRoute.parentRefs` must not be empty | a route without a parent attaches to **no** Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer. | +| `httpRoute.hostnames` must not be empty | an `HTTPRoute` with no hostnames does not render nothing — it matches **every** request that reaches its listener. On a shared Gateway this release would then answer for hostnames belonging to other releases. | + +**One `HTTPRoute` carries all the hostnames**, and that is a property of this chart rather than a convention. Nothing differs per host: every name serves the same Service at `/`, which was measured on the live Ingress (three host rules, byte-identical path and backend). `spec.hostnames` is a list and a route's rules apply to every hostname of the object, so one object with one rule is the complete answer. Compare the charts where that would be wrong: `homeassistant` renders one route **per host** because its paths differ per host, `matrix-synapse` renders two because homeserver and delegation want different paths *and* different backends, and the Keycloak realms of Gitea #122 would need one each because `app-root` differs per host. + +**What moves to the platform.** In Gateway mode the chart renders no `Gateway`, no `gatewayClassName`, no `Certificate` and — deliberately — no implementation-specific policy object (`ClientSettingsPolicy`, `ClientTrafficPolicy` and their equivalents). Only `HTTPRoute` with `parentRefs` looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, in `cluster-config`. + +To check an attachment: + +```bash +kubectl -n rss-bridge get httproute rss-bridge-rss-bridge-httproute -o yaml +``` + +`Accepted: True` and `ResolvedRefs: True` under `status.parents[]` is a working attachment. An **empty** status means no controller has looked at the route at all — usually a `parentRefs` pointing at a Gateway that does not exist, or a listener whose `allowedRoutes` does not admit this namespace. + +### Why `httpRoute.hostnames` is its own value + +Across the repository there are **three** shapes for the hostname in Gateway mode, and which one a chart gets follows from its values rather than from taste: + +1. **Fallback to `ingress.domain`** — most charts. An empty `hostnames` means `[ingress.domain]`, so there is a single source of truth. +2. **No `hostnames` key at all** — `freesailarr` and `homeassistant`. Neither has an `ingress.domain`, and a chart-global list of names could not say what it belonged to: one of ten bundled apps, or one host entry and therefore one set of paths. The names stay where they already live, and the schema rejects a `hostnames` key outright. +3. **A mandatory `hostnames` with no fallback** — `rss-bridge`, and this is the only chart in that shape. + +This chart has no `ingress.domain` either, so there is nothing to fall back *to*. But unlike the second group its names are unambiguous: one Service, one path, so a chart-global list says exactly one thing. What rules out simply reusing `ingress.hosts` is the other half of those entries — each carries an optional `tlsSecretName`, which exists only to keep three grown certificates on their existing names and which addresses **nothing** once TLS sits on the Gateway's listener. Reusing the list would carry a structure whose second field is meaningless into the new mode, and invite somebody to set it there. + +So `httpRoute.hostnames` is a plain list of strings, and it is mandatory. + +**A corollary: `rss-bridge.tlsGroups` is inert in Gateway mode.** That helper's entire purpose is to group hosts by TLS secret name; with the certificate on the listener there is nothing left to group. Both of its callers — the Ingress template and the Ingress branch of `NOTES.txt` — sit behind `ingress.enabled`, so this mode never invokes it. Worth stating rather than leaving to be noticed: it is the most intricate part of this chart's Ingress, and its absence from the Gateway path is a **simplification**, not an omission. + +### Nothing is lost in Gateway mode, and that is measured + +`ingress.annotations` has no counterpart in the Gateway API, so for most charts of the sweep the switch costs something concrete — an upload limit, a buffer size, a CORS block, an auth annotation. Group 3 of the sweep screened the **live** Ingress objects rather than the charts, precisely because an operator's annotation can fill a gap the chart never sets, and found exactly such an annotation on four of its six charts. + +That screen was run here too, read-only against the running object (2026-10-13), and it came back **empty**. The live Ingress carries `cert-manager.io/cluster-issuer`, the two `meta.helm.sh/*` keys, Fleet's `objectset.rio.cattle.io/id` and Rancher's generated `field.cattle.io/publicEndpoints` — and **not one `nginx.ingress.kubernetes.io` annotation**. No body-size limit, no buffer sizes, no CORS, no rewrite, no auth. + +`rss-bridge` is therefore the **only chart of the sweep whose Gateway branch has no loss to explain**, and its `NOTES.txt` says so positively instead of carrying over a loss paragraph from another chart. Two things do become inert, and neither is a loss: `ingress.annotations` (empty here) and the TLS secret grouping described above. + +The usual caveat still applies: that is an absence of evidence about **this** installation, not a guarantee about another. Anything a future installation puts in `ingress.annotations` disappears in Gateway mode exactly as it does elsewhere. + +### A value derived from `ingress.enabled` — screened, and clean + +Gateway mode sets `ingress.enabled: false` by construction, so any value *computed* from that switch flips when the mode changes. Where such a value 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 — a green pod serving a broken surface. The sweep found three of these: `sure` (`RAILS_FORCE_SSL`), `freesailarr` (`__SERVER__URLBASE`) and `ytdlp-web-player` (a silent authentication bypass). + +Screened here over the whole chart directory, with a **clean** result. Outside the Ingress template the only readers of an ingress value are `NOTES.txt`, which knows its own mode, and the `tlsGroups` helper, which is called only from behind the switch. The Deployment, Service and ConfigMap read none of them. RSS-Bridge takes no base URL and no hostname in its environment — nginx serves as `default_server` without a `server_name` — so there is nothing that could come out wrong behind a `Ready` pod, and no `rss-bridge.published` helper is needed. + +A clean result is worth recording: the screen's value is that it is run, not that it always finds something. + ### Several hosts, several certificates +**Ingress mode only** — in Gateway mode the certificate sits on the Gateway's listener and this whole mechanism is inert (see above). + This is a **documented deviation** from the repo's one-TLS-secret rule ([style guide §4](Chart-Style-Guide)). Entries are grouped by `tlsSecretName` in first-appearance order: entries sharing a name become one `tls` entry and therefore one certificate with several SANs, entries with distinct names get one certificate each, and entries without a name fall into the conventional `tls--rss-bridge-ingress` group. ```yaml @@ -160,15 +256,60 @@ The template quotes every `value` because a value like `"5"` or `"true"` renders Nothing to back up: no database, no volumes, no user data. The feed cache is disposable by design, so `helm rollback` and app-version bumps carry no data risk. -App versions are date tags (`2025-08-05`). Renovate tracks them with a regex versioning of `^(?\d{4})-(?\d{2})-(?\d{2})$`, and the bump script normalizes the dashes to dots for the `+up` suffix — hence `1.0.2+up2025.08.05` for `appVersion: "2025-08-05"`. See [Versioning](Versioning) and [Renovate](Renovate). +App versions are date tags (`YYYY-MM-DD`). Renovate tracks them with a regex versioning of `^(?\d{4})-(?\d{2})-(?\d{2})$`, and the bump script normalizes the dashes to dots for the `+up` suffix — so an `appVersion: "2026-09-30"` pairs with a chart version ending `+up2026.09.30`. Read the current pair off `rss-bridge/Chart.yaml`. See [Versioning](Versioning) and [Renovate](Renovate). + +Note that the image also publishes `latest`, `stable` and `sha-*` tags; only the date tags are tracked, which is why a newer `sha-*` tag is not an outstanding update. Note as well that app updates to this chart went through while its install-test was red — Renovate itself will not merge a red PR, so those arrived by hand (Gitea #51, PR #62). ## CI `ci/rss-bridge/test-values.yaml` sets the required ingress values with **three hosts across two TLS secret names** — two sharing one, a third with its own — so the install-test renders the secret-grouping helper as well. That helper is what keeps the three existing certificates of the takeover installation alive, and it is not exercised by the chart defaults; an install-test that never rendered it would not notice if it broke. `whitelist` is set to a concrete list (`Youtube`, `Heise`) rather than the default `*`, so the ConfigMap render and the `subPath` mount are covered by something the app has to parse. There is no `fixtures.yaml` — nothing to stand up. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). +**The Gateway mode is covered by a render overlay, not by the install-test.** `ci/httproute/rss-bridge.yaml` switches the chart into Gateway mode for `ci/run-httproute-match-test.sh`, which holds every `backendRefs` entry against the Services of the same render and refuses an empty `parentRefs`. An overlay rather than a change to the fixture, and here the reason is sharper than for its siblings: the fixture's Ingress is the only thing that exercises `tlsGroups`, which is inert in Gateway mode, so a fixture switched over would stop covering it entirely. The overlay is also the **second** in the repository that has to *supply* hostnames (`homeassistant`'s was the first), because this chart has no fallback. + +Since the install-test installs the Ingress mode, the overlay is the only automated coverage the Gateway mode of this chart has — the ordinary situation across the sweep, not a special weakness of this chart. + +### The install-test, which was red on purpose and then went green + +**Current state: green.** On 2026-10-13, in the Gateway-API conversion PR, this chart's install-test passed for the first time since the move to Gitea — k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no permission error anywhere in the job. + +**The chart is ruled out as the cause, and that is measured rather than argued.** That PR's rendered output for the fixture values is **byte-identical** to `origin/main`, so the pod spec that installed is exactly the one `main` produces — the same spec that had been failing. Whatever changed is therefore not in the chart, which is precisely where Gitea #51 had already placed the burden of proof. + +Two cautions belong with the result, and they are the reason #51 was not simply closed: + +- **It is one run.** An intermittent effect needs several runs per side before either answer is honest, and nobody has yet identified *what* changed on the runner. +- **The colour is not the finding.** The standing instruction on #51 is that whoever sees this test green closes the issue *after checking the cause*, not because of the colour. + +The history below is kept because it is the evidence that localised the defect, and because a recurrence would be read against it. + +The failure mode was: php-fpm runs as uid 33 and cannot read `/app/index.php`; the startup probe gets a 403 through its whole 150 s budget and the pod never becomes `Ready`. + +What has been measured, and what each measurement rules out: + +| Measurement | What it settles | +|---|---| +| The running instance is healthy — `Ready=True`, **0 restarts**, on the same image | the chart and the image are not the cause; production is unaffected | +| The runner pulls the **same digest** that Docker Hub serves for the tag | the "the tag was re-pushed" theory is dead | +| `/app/index.php` is mode `0644` inside a `0755` directory — world-readable | ordinary file ownership cannot explain it, which also killed the original `userns-remap` hypothesis | +| Reproduced on **two** images four weeks apart (`2025-08-05` and `2026-09-30`) | an image change will not fix it | +| **23 of 24** charts come up on the same runner, nearly all non-root with `readOnlyRootFilesystem` | weakens any explanation that is purely about the runner denying non-root access to image files | + +That last row is the unresolved contradiction: it looks like a general filesystem effect, yet it hits exactly one chart. The candidates left are a FUSE/overlay mount the process cannot reach, an LSM denial (AppArmor, SELinux), or something php-fpm-specific — it emits this same text when the `SCRIPT_FILENAME` nginx hands it is not resolvable for the worker, not only when read permission is missing. + +**The chart was deliberately *not* listed in `ci/excluded-charts.txt`, and that decision paid off.** The alternative was on the table — an entry with this issue as its reason, pipeline honestly green instead of red-and-ignored — and it was **rejected**, because an exclusion silences the signal. The argument made at the time was that on the day the runner stopped causing this, the test would go green by itself and that is how anyone would find out. That is exactly what happened, and an excluded chart would have passed the whole time and told nobody. + +The red install-test was therefore the **status indicator for Gitea #51**, which is why it mattered that it was red and why it matters that it is now green. + +Two rules for anyone working on this chart, both still standing: + +- **Do not weaken the chart to make the test pass.** Not the probes, not the budgets, not the security context, not `runAsUser`. The chart runs in production exactly as it is, and the green above was obtained without changing any of them — by construction, since the render was byte-identical. +- **If it goes red again, that is a finding, not a known quirk.** It would also be the control case the single green run is missing. + +A consequence for the Gateway mode in particular: the install-test installs the **Ingress** mode, because that is what the fixture sets. So the Gateway mode of this chart is covered at render level and nowhere else — `helm lint --strict`, the `httproute-match` assertions and the schema. That is the same position as all 21 other charts of the sweep, no better and no longer worse. + ## See also -- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the TLS-secret deviation it documents +- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, the Gateway-API form, and the TLS-secret deviation it documents +- [CI/CD](CI-CD) — the `httproute-match` render test, the Gateway-API CRD fixture, and why this chart's install-test is red - [Versioning](Versioning) — what a bump of `1.0.2+up2025.08.05` means - [Renovate](Renovate) — date-tag versioning for this chart - [Lessons learned](Lessons-Learned)