diff --git a/CI-CD.md b/CI-CD.md index 7cb6475..b5b7597 100644 --- a/CI-CD.md +++ b/CI-CD.md @@ -137,20 +137,23 @@ The third job that ignores the changed-chart matrix, and for the same reason as |---|---|---| | `parentRefs` empty | the route attaches to **no** Gateway: created, schema-valid, rejected by nobody, routing nothing | nobody — no event, no condition, the only symptom is a hostname that does not answer | | `backendRefs` naming a Service, or a port, that does not exist | the reference is resolved **by name** at runtime and validated by nothing | nobody in CI — `ResolvedRefs: False` lands in a route status that only a Gateway controller writes, and none runs here | +| a prefix stripped with `ReplacePrefixMatch` while the same route carries no `Exact` match on that prefix with a `RequestRedirect` filter (Gitea #118) | the **slashless** form of the path is rewritten to `/` and answered **200** by the app, under a document URL that still omits the prefix — so the page's relative links resolve against the host root, which on a shared host is a *different* application | nobody — the app's own assets and XHRs are served by its neighbour, and the symptom is a half-working UI rather than an error | -**The real Gateway-API CRDs in the install-test do not close either gap**, which is the whole reason this job exists next to them: the Gateway-API schema accepts an empty `parentRefs` list and a `backendRefs` entry pointing at a Service that was never created. The CRDs prove the object is *well-formed*; this test proves it *points somewhere*. +**The real Gateway-API 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 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 third finding 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 it a question of existence rather than of order, because an `Exact` path match outranks every prefix match: the redirect rule wins wherever it is emitted, and a chart's rule ordering stays a rendering convention. [freesailarr](freesailarr) is the first chart it applies to; [bulwark](bulwark), the second `rewrite-target` chart, meets the same check when group 3 of the sweep lands — which is why the rule lives in the harness and not in one chart's template. The port half is a genuinely new exposure rather than a hypothetical. An Ingress backend references its Service port **by name**; `backendRefs` takes a `PortNumber` and cannot. So from now on the number in the route and the port in the Service are kept in step by hand, and this test is what holds that pair. Three things to know before touching it: - **Helm is the YAML parser**, through `ci/yaml2json`, for exactly the same reason as in `selector-match` above — no pyyaml, no `yq`, and a hand-written parser fails by reading nothing and reporting nothing. -- **What is deliberately not checked.** *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable and that claim is made nowhere in this pipeline. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule carrying no `backendRefs` at all (legal for a filter-only rule such as a redirect) — all three are **reported as "not checked", never counted as passing**. +- **What is deliberately not checked.** *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable and that claim is made nowhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing beyond that: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule carrying no `backendRefs` at all (legal for a filter-only rule such as a redirect) — all three are **reported as "not checked", never counted as passing**. Note that the two interact honestly: the redirect rule the pairing check demands is itself backend-less, so a chart that satisfies the check also earns one "not checked" line, and both verdicts are correct at once. - **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/.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. +- **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. - **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 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, and a run with zero assertions — 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 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 brings 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. 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) diff --git a/Lessons-Learned.md b/Lessons-Learned.md index 3745ffd..9d0671a 100644 --- a/Lessons-Learned.md +++ b/Lessons-Learned.md @@ -540,6 +540,32 @@ Measured before and after, which is what distinguishes this from an argument: `h **The generalisation, which is the point of recording it twice:** the question a chart asks `ingress.enabled` is almost never "does an Ingress object exist". In `sure` it was "is TLS terminated in front of me". Here it was "is this app published at all, and under which prefix". Both are properties of the *release*, not of the object — and both answers flip when the object changes while the property does not. Before adding a second routing mode, read every `ingress.enabled` outside the Ingress template and ask what the value is standing in for; where it stands in for something, the fix is one helper and never a new value. +## The gap the chart never had, and the operator had already closed + +**Where:** [freesailarr](freesailarr), Gitea #118. + +**What it was:** qBittorrent has no base-path setting, so the chart serves it under a prefix and strips that prefix. A request for the **bare** prefix — `/qbittorrent`, no trailing slash — is stripped to `/`, qBittorrent answers it **200** with its login page, and the browser's document URL keeps the prefixless form. Every relative link in that page then resolves against the host root, which on this chart's shared host is **seerr**. The login form comes up unstyled and does nothing while a different application answers its assets and XHRs. Nothing fails: not a probe, not a render, not the schema, not the install. The symptom points at qBittorrent, and the fault is in the URL. + +**What made it a lesson rather than a bug.** The chart had **never** shipped a fix for this, in either routing mode — the pickaxe searches for `configuration-snippet` and for `return 301` over `freesailarr/` both return zero commits, and `values.yaml` did not mention the trap at all. And yet `curl -D - https://freesailarr.jk-dev.de/qbittorrent` answered **301** to the slashed form, and had done for as long as anyone had used it. The redirect was real, and it came from somewhere else: an `nginx.ingress.kubernetes.io/configuration-snippet` on the **live** Ingress objects, supplied through the chart's global `ingress.annotations` by **Fleet, from the separate `jk-cluster-config` repository**. An annotation of risk level `Critical`, on a controller deliberately started with `allow-snippet-annotations=true`, in a repository the chart knows nothing about. + +So three things were true at once, and each one made the others harder to see: + +- the chart was **incomplete** — it documented neither the trap nor the remedy; +- the running system was **correct** — the redirect was served; +- the Gateway-API migration therefore *looked* like it would break something the chart had never provided. The behaviour would have disappeared on migration, and the diff that removed it would have contained nothing: the annotation channel simply does not exist in Gateway mode. + +**Now:** the Gateway mode renders the redirect itself, as an `Exact`-match rule with a `RequestRedirect` filter (301 — `statusCode` defaults to 302, and only 301/302 are Core in that API, so an Extended value an implementation declines fails the **whole route** and takes the neighbouring apps with it). The Ingress mode is **unchanged on purpose**: its only mechanism is the snippet annotation, which is a silent no-op on any cluster that keeps `allow-snippet-annotations` at its post-1.9 default of off — so the chart writes the snippet, and its price, into `values.yaml` instead of shipping a no-op. The operator's snippet is **not redundant** and must not be removed: `freesailarr.jk-dev.de` still runs in Ingress mode, where it is the only thing performing the redirect. `ci/run-httproute-match-test.sh` now asserts, per `HTTPRoute`, that a prefix-stripping rewrite is accompanied by a redirect for the bare prefix — see [CI/CD](CI-CD) — so the gap cannot reappear in the chart that has it or in the next chart to strip a prefix. + +### The method half, which generalises further than the bug + +The investigation produced a **correct search** and a **wrong conclusion**, and the gap between them is the part worth keeping. + +"The chart has never contained this redirect" was measured properly: the right paths, the right pickaxe searches, zero hits, no ambiguity. What it does not support — and what was briefly inferred from it anyway — is "therefore the redirect is not happening". A chart is one input to a running system. The others are the cluster repository's values, the controller's own flags, anything an admission webhook injects, and whatever a previous operator added by hand. + +**So: a search over the chart answers what the chart does. What the running system does is a separate measurement** — at the live object (`kubectl get ingress -o yaml`, and read the annotations, including the ones no chart template could have written) or in the cluster repository that supplies the values. The `objectset.rio.cattle.io/id` annotation named Fleet and the bundle in one line; the question had simply not been asked. + +**Where a chart finding is meant to carry a claim about behaviour, that second measurement is mandatory.** Not as diligence, but because the two can disagree in both directions and both directions are expensive: a chart that is missing something the cluster supplies (this case — a migration silently drops it), and a chart that supplies something the cluster overrides (where the chart's documentation is wrong about the system and nobody notices until the override moves). + ## The recurring shape - **Green is a claim about the assertions that ran, not about the system.** @@ -580,4 +606,6 @@ Measured before and after, which is what distinguishes this from an argument: `h - **A fixture is defined by what it validates, not by what it stands in for.** The same trick — a schema-less stub CRD — is correct for one API and worthless for the next, and the question that separates them is whether the schema is the thing under test. Reusing a pattern because the shape matches is how a green run comes to mean "something was created". - **When the fix needs a privilege, ask whether two cheap steps can replace one privileged one.** Splitting a pre-build into a seeding container plus a build container kept the hardening baseline intact across the whole pod, where the shorter single-container version would have bought a documented deviation *and* a suppression that silences the container that actually serves traffic. - **A values flag can be correct and still be the wrong question.** When one value answers two questions that happen to coincide, the day they stop coinciding is a day nothing fails: the flag still renders, the pod still starts, and the second question is now answered wrong. Adding a second mode to anything is the moment to ask which of its readers were reading it for the *other* reason. +- **A search over the chart answers what the chart does; what the running system does is a second measurement.** The chart is one input among several — the cluster repository's values, the controller's flags, an admission webhook, a previous operator's hand edit. Where a finding about a chart is meant to carry a claim about *behaviour*, read the live object or the repository that supplies its values; a correct search over the wrong scope produces a confident wrong answer. +- **An operator can close a chart's gap invisibly, and the chart still looks correct.** Where the closure lives in another repository, the chart is incomplete, the system works, and the change that removes the closure contains no trace of it — which is why "this has always worked" is evidence about a deployment and not about a chart. - **A "nothing else reads this" note is a measurement, not a property.** It is true on the day it is written, it is what later decisions rest on, and the change that falsifies it is the change obliged to rewrite it — in every copy, the schema description included. diff --git a/freesailarr.md b/freesailarr.md index d5d98da..75f06f1 100644 --- a/freesailarr.md +++ b/freesailarr.md @@ -69,7 +69,7 @@ httpRoute: sectionName: https ``` -Both shapes work unchanged, and the sub-path shape gets **simpler**: qBittorrent's prefix-stripping rewrite becomes a per-rule `URLRewrite` filter, so it no longer needs a routing object of its own. See [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). +Both shapes work unchanged, and the sub-path shape gets **simpler**: qBittorrent's prefix-stripping rewrite becomes a per-rule `URLRewrite` filter, so it no longer needs a routing object of its own. It is also the one place where the Gateway mode does something the Ingress mode cannot at all — it renders the redirect for the **bare, slashless** prefix itself. See [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) and [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix). `helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set, including the computed startup budgets, the bazarr base-URL step and the warning when `firewallOutboundSubnets` is still empty. Read it — it is the per-release version of this page. @@ -131,7 +131,7 @@ Defaults for all apps; `clusterIssuer`, `className` and `annotations` are overri | `ingress.host` | string | `""` | Empty = one host per app (shape 1). Set = shared domain with sub-paths (shape 2) for every app without a host of its own. **Read by both routing modes** — it names the public address of the stack and is not an Ingress switch. The same holds for the per-app `.ingress.host`. | | `ingress.clusterIssuer` | string | `null` | **Required for every Ingress object that renders** and is not overridden by an `.ingress.clusterIssuer`. cert-manager `ClusterIssuer`. Inert in Gateway mode. | | `ingress.className` | string | `nginx` | Ingress class. The shared-domain qBittorrent object relies on nginx's `rewrite-target`. Inert in Gateway mode — the class sits on the Gateway, and the rewrite becomes a filter. | -| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, per-app overrides included: the Gateway API has no annotation channel. The chart needs nothing from it there, but an annotation *you* added is gone without a word — a body-size cap for qBittorrent's torrent-file uploads being the likely one. | +| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, per-app overrides included: the Gateway API has no annotation channel. The chart needs nothing from it there, but an annotation *you* added is gone without a word — a body-size cap for qBittorrent's torrent-file uploads being the likely one. One annotation **is** worth adding here in Ingress mode and the chart cannot ship it: the `configuration-snippet` redirecting the bare `/qbittorrent` to `/qbittorrent/` — see [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix). In Gateway mode it is neither needed nor possible, so unlike the body-size case nothing is lost on migration. | TLS secrets: `tls--freesailarr--ingress` per own-host object, `tls--freesailarr-ingress` for the shared one. In Gateway mode there are none — the certificates are the Gateway listener's. @@ -149,7 +149,7 @@ The Gateway-API alternative to the Ingress, **off by default**. Added with group What the chart renders in this mode: - **Shape 1 (own host):** one `HTTPRoute` per app, `-freesailarr--httproute`, hostname `.ingress.host`, one rule at `PathPrefix: /` pointing at that app's Service. -- **Shape 2 (shared host):** **exactly one** `HTTPRoute`, `-freesailarr-httproute`, hostname `ingress.host`, one rule per app at its `.ingress.path` — qBittorrent included, with its `URLRewrite` filter on that one rule. +- **Shape 2 (shared host):** **exactly one** `HTTPRoute`, `-freesailarr-httproute`, hostname `ingress.host`, one rule per app at its `.ingress.path` — qBittorrent included, with its `URLRewrite` filter on that rule. qBittorrent is the one app contributing **two** rules: the prefix-stripping one, and an `Exact` match on the bare, slashless prefix carrying a `RequestRedirect` filter (301) to the slashed form. See [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix). - Mixed operation falls out as before: an app with a host of its own keeps its own route. Every `backendRefs` entry carries the port **number**, where the Ingress addresses the same Service port by **name** (the app name). Both come from the same `freesailarr.portApps` helper the Services render from, so they cannot drift, and `ci/run-httproute-match-test.sh` holds the pair anyway. @@ -263,6 +263,8 @@ Two mutually exclusive modes, and exactly one of them may be on. `ingress.enable **Here the Gateway API is *less* machinery than what it replaces, and that is the reason this chart was worth converting.** `rewrite-target` applies per Ingress **object**, and qBittorrent's neighbours must not be rewritten — their UrlBase contains the prefix, so they expect the full path. That forced a second Ingress object for qBittorrent on the shared host, which then had to repeat the main object's TLS secret name, **omit** the cert-manager annotation so only one certificate is requested (conditionally, for the case where no main object renders at all), and match an `ImplementationSpecific` **regex** path whose capture group the rewrite refers to. A `URLRewrite` filter applies per **rule**: one route, one rule per app, the filter on qBittorrent's rule, a plain `PathPrefix`. All four of those crutches disappear. +And in one respect the Gateway mode is not less machinery but **more capability**: it renders the 301 for the bare, slashless `/qbittorrent` that the Ingress mode cannot render at all, because nginx offers only a snippet annotation for it and snippets are off on a default controller. That is the single behavioural difference between the two modes in this chart — [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix) has the whole of it. + **What does *not* get better is anything that was an annotation and is not a filter.** This chart sets no body-size annotation of its own, so nothing it needs is lost — but `ingress.annotations` is the only lever an operator has for one, and qBittorrent takes torrent-file uploads. In Gateway mode that lever does not exist in the chart at all; the limit belongs to the Gateway or to its implementation's own policy object. **The sub-path environment variables follow the shape, not the mode — and that took a fix.** `__SERVER__URLBASE` for radarr, sonarr and prowlarr is derived from the same validated list the routing objects use, and that list was gated on `ingress.enabled`. In Gateway mode that value is `false` by construction, so the list would have come out **empty** while the routes served those apps under `/radarr`, `/sonarr` and `/prowlarr`: no UrlBase, assets and API calls linked at the host root, a broken UI behind a `Ready` pod and nothing in any log. The two guards in that helper — seerr and mediathekarr only at `/`, no duplicate paths on the shared host — would have gone unenforced in that mode for the same reason. The list now asks `ingress.enabled OR httpRoute.enabled`, which is the general form in [Lessons learned](Lessons-Learned). @@ -284,9 +286,40 @@ A per-app host always wins, so a mixed setup needs no extra values. The cost per - **radarr, sonarr, prowlarr — nothing.** The chart sets `__SERVER__URLBASE` itself as soon as the app renders under a path other than `/`, unless that app's own `env` block already carries the variable (then yours wins and the chart stays out). Side effect worth knowing: with a UrlBase set, these apps answer the bare `/ping` with `307` and a `Location` under the base. The probes keep passing — the kubelet counts 200–399 as success — so the probe paths stay `/ping` in both shapes. - **bazarr — one manual step.** It reads its base path only from its own configuration; there is no environment variable. After the first start set *Settings → General → Base URL* to the same value as `bazarr.ingress.path` and restart the pod. Until then the UI is broken **without announcing it**: bazarr answers every unknown path with its index page, so `/bazarr/` and `/bazarr/` both return `200 text/html` of identical length. Check the content type, not the status code. Deliberately not automated — seeding it would mean writing into bazarr's config database. -- **qbittorrent — the prefix has to be stripped, and what that costs depends on the mode.** In **Ingress mode** it gets its own object with an nginx `rewrite-target`, on the same host and sharing the main object's TLS secret (the cert-manager annotation sits on the main object, or on this one when it is the only app on the shared host). In **Gateway mode** it is simply one rule of the shared route carrying a `URLRewrite` filter, because a filter is per rule — no second object, no shared secret, no conditional annotation, no regex path. Two upstream traps apply in both modes, and both look like a login failure: the session cookie is marked `Secure`, so over plain HTTP the browser never sends it back and every click bounces to the login form; and qBittorrent bans the client IP for a while after a few failed attempts, after which even correct credentials fail until the ban expires. Fallback without a chart change: set `qbittorrent.ingress.host`. +- **qbittorrent — the prefix has to be stripped, and what that costs depends on the mode.** In **Ingress mode** it gets its own object with an nginx `rewrite-target`, on the same host and sharing the main object's TLS secret (the cert-manager annotation sits on the main object, or on this one when it is the only app on the shared host). In **Gateway mode** it is one rule of the shared route carrying a `URLRewrite` filter, because a filter is per rule — no second object, no shared secret, no conditional annotation, no regex path — plus a second rule for the redirect below. **Call the path with a trailing slash in Ingress mode**, and read [The trailing slash on qBittorrent's prefix](#the-trailing-slash-on-qbittorrents-prefix) before deciding you do not need to. Two upstream traps apply in both modes, and both look like a login failure: the session cookie is marked `Secure`, so over plain HTTP the browser never sends it back and every click bounces to the login form; and qBittorrent bans the client IP for a while after a few failed attempts, after which even correct credentials fail until the ban expires. Fallback without a chart change: set `qbittorrent.ingress.host`. - **seerr and mediathekarr — pinned to `/`.** seerr has no base-path support ([upstream issue open since May 2022](https://github.com/Fallenbagel/jellyseerr/issues/97)); mediathekarr links its UI assets with root-absolute paths, which a prefix-stripping rewrite cannot fix. Any other path **fails the render** rather than producing a broken UI, and two apps claiming the same path on the shared host fail the render too — one of them then needs its own host. With seerr off the shared domain's root answers 404; that is correct and left alone. +### The trailing slash on qBittorrent's prefix + +qBittorrent has no base-path setting, so in the shared-domain shape the prefix is stripped before the request reaches it. That is correct for the slashed form and **wrong for the bare one**, and the wrongness is silent at every level. + +A request for `https:///qbittorrent` — no trailing slash — is stripped to `/`. qBittorrent answers it **200**, with its login page. But the browser's document URL stays `/qbittorrent`, so every **relative** link in that page resolves against the *parent* of that path, which is the host root — and `/` on the shared host is **seerr**. Assets, XHRs and form posts all go to seerr. What you see is an unstyled login form that does nothing, while another application answers its requests. Nothing logs an error, nothing fails a probe, and seerr appearing in a qBittorrent request's path is not a clue anyone follows quickly. + +`https:///qbittorrent/` works. The same applies to any custom `qbittorrent.ingress.path` — it is a property of prefix stripping, not of the default value. + +**In Gateway mode the chart renders the redirect** (Gitea #118). The shared `HTTPRoute` carries a second rule for qBittorrent: an `Exact` match on the path with a `RequestRedirect` filter, `path.type: ReplaceFullPath` to the slashed form, `statusCode: 301`. Three things about it are worth knowing because they are decisions, not details: + +- **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. + +**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): + +```yaml +ingress: + annotations: + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($uri = "/qbittorrent") { return 301 $scheme://$host/qbittorrent/$is_args$args; } +``` + +**Its price, and why the chart does not ship it:** snippet annotations are honoured only on a controller started with `allow-snippet-annotations=true`, which has been **off by default since ingress-nginx 1.9**, and switching it on also means accepting annotations risk level `Critical` — a configuration snippet is arbitrary nginx configuration injected from a namespaced object. A chart that shipped this annotation would render a **silent no-op** on every cluster keeping the default, which is worse than naming the gap. The decision belongs to whoever runs the ingress controller. + +**A third Ingress object with `pathType: Exact` is not a substitute**, which is the obvious-looking alternative and does not work on this hostname. In ingress-nginx `controller-v1.14.5`, `internal/ingress/controller/template/template.go`, `buildLocation()` returns a plain regex location (`~* "^"`) in its `enforceRegex` branch and **returns there** — before ever reaching the `PathTypeExact` branch that would emit the `= ""` location nginx gives precedence to. And `enforceRegex` is computed **once per nginx `server`**, not per location: `rootfs/etc/nginx/template/nginx.tmpl` evaluates `enforceRegexModifier` over every location of the hostname and it returns true as soon as one of them needs a rewrite. qBittorrent's own object carries `rewrite-target` on that very hostname, so `enforceRegex` is true for the whole server and an `Exact` path renders as an ordinary regex location. The exact-match precedence the idea depends on never comes into being. + +That short-circuit alone settles it. What an unanchored regex location would then do to *ordering* — shadow the app, or loop the redirect, depending on emission order — is **inferred from the same sources and has not been observed on a running nginx**; it is not needed for the conclusion. + +**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. + ### Every Service points at the same pod IP One ClusterIP Service per enabled app that listens on a port (recyclarr has none). Because all containers share gluetun's namespace, the Services differ only in their port: they give stable per-app DNS names, **not** network separation. mediathekarr's Service carries both its ports; only the primary (5007) is ever addressed by a routing object.