Gateway-API mode for group 2 of #114: the five annotation charts
PR #117. calibre-web-automated, outline, paperless-ngx, zipline (proxy-body-size) and freesailarr (rewrite-target) each gain an httpRoute block, off by default. Per chart page: a Routing row instead of the Ingress row, a Gateway-mode install snippet, an httpRoute values table, and a "Routing: Ingress or Gateway API" operating note. The upload-limit disclosure is written in each chart's own terms rather than copied: an e-book library (calibre), an upload service (zipline), the web-UI half while the consume directory keeps no limit (paperless), and the open #12 pair (outline). freesailarr sets no body-size annotation and needs none, but ingress.annotations is its operator's only lever for one. outline: #12 now says what Gateway mode does to it. The ingress half of the pair disappears, so the effective ceiling is the Gateway's and neither documented number necessarily decides. That moves the defect; it does not fix it. paperless-ngx: it was the last chart rendering its Ingress unconditionally and now has ingress.enabled, default true. Recorded with the reason, because the page previously stated the absence of that switch as a property. freesailarr: the page gains the finding and the improvement. Its shared-host app list was gated on ingress.enabled and also feeds the <APP>__SERVER__URLBASE variables, so Gateway mode would have served radarr/sonarr/prowlarr under their prefixes with no base path set - broken UI, Ready pod. And its rewrite-target crutch is gone: a URLRewrite filter is per rule, so qBittorrent needs no second routing object, no repeated TLS secret, no conditional cert-manager annotation and no regex path. Chart-Style-Guide §4: the "needs one, not sets one" screen, the rewrite-target asymmetry as the one place this mode is less machinery, the ingress.enabled-first rule for a chart without that switch, the freesailarr hostnames exception, and the group status. §12.1 gains the two addenda the sweep produced. CI-CD: the HTTPRoute test figure (4 over 2 at the pilot, 20 over 10 after group 1, 36 over 15 now), the multi-app overlay rule, and the honest note that the Gateway branch of NOTES.txt is executed by that test but printed by no stage. Lessons-Learned: the freesailarr case in full, next to sure's. Same helper shape, different symptom - which is the point: ingress.enabled is almost never asked "does an Ingress exist", and the property it stands in for does not flip with the object.
8 files changed
+374
-55
No files matched your search
+4
-2
@@ -146,9 +146,11 @@ 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**.
|
||||
- **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, a run in which **no assertion at all** held fails. Today `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode) and `template-chart` through its overlay.
|
||||
- **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, 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.
|
||||
- **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): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. 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 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.
|
||||
|
||||
### 6. `trivy-config` (reporting only)
|
||||
|
||||
|
||||
+18
-6
@@ -33,13 +33,13 @@ See [Versioning](Versioning) for the full rules. Short form: `version: <chart-se
|
||||
- `ingress.annotations` takes optional extra annotations, merged with the cert-manager annotation the chart always sets.
|
||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
||||
|
||||
**A shared domain with sub-paths is the second admissible shape.** Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (`example.com/sonarr`, `example.com/radarr`). Rules for the shared shape: the domain is a single value (`ingress.host`); each app's path is its own value defaulting to `/<app>`; the paths render in one Ingress object with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the TLS secret name and **omitting** the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
|
||||
**A shared domain with sub-paths is the second admissible shape.** Where a chart exposes several apps they may each take a host, or share one host and be told apart by path (`example.com/sonarr`, `example.com/radarr`). Rules for the shared shape: the domain is a single value (`ingress.host`); each app's path is its own value defaulting to `/<app>`; the paths render in one Ingress object with the standard TLS secret; an app carrying a host of its own always wins and keeps its own object, so both shapes mix without extra values. An app needing a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the TLS secret name and **omitting** the cert-manager annotation, so exactly one certificate is requested. Apps that cannot serve a base path are rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work. **In Gateway mode that extra object is not needed**: a `URLRewrite` filter is per rule, so the prefix-stripping app is simply one rule of the shared route — see below.
|
||||
|
||||
**Documented exception — homeassistant** keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `className` default follows the standard.
|
||||
|
||||
**Documented exception — rss-bridge** takes `ingress.hosts` as a required list of host plus optional `tlsSecretName`, so several hosts keep their own existing certificates.
|
||||
|
||||
**Documented exception — freesailarr** has no `ingress.domain` at all and offers both shapes; see [its page](freesailarr).
|
||||
**Documented exception — freesailarr** has no `ingress.domain` at all and offers both shapes — and therefore no `httpRoute.hostnames` either, the only chart without that key; its hostnames stay in `ingress.host` and `<app>.ingress.host`. See [its page](freesailarr).
|
||||
|
||||
### The Gateway API is the second routing mode
|
||||
|
||||
@@ -61,14 +61,21 @@ The rest of the rules, each against a failure that reports nothing:
|
||||
| **A value, never auto-detection** | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the Gateway-API CRDs fails at *install* time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
| **Mutually exclusive with `ingress.enabled`**, render abort naming **both** value paths | two data paths onto one Service under one hostname is a conflict at DNS level already. A migration runs over a **second hostname** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
|
||||
| **`parentRefs` mandatory in Gateway mode**, render abort on the empty list | a route without a parent attaches to nothing: created, schema-valid, rejected by nobody, routing nothing — no event, no condition, the only symptom a hostname that does not answer. |
|
||||
| **One source of truth for the hostname** — empty `hostnames` means `[ingress.domain]` | two values that have to agree drift apart. `ingress.domain` is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. |
|
||||
| **One source of truth for the hostname** — empty `hostnames` means `[ingress.domain]` | two values that have to agree drift apart. `ingress.domain` is read in Gateway mode too: it names the release's public hostname and is not an Ingress switch. A chart without an `ingress.domain` omits `hostnames` and keeps its existing host values — `freesailarr` is the one such case. |
|
||||
| **A chart that still renders its Ingress unconditionally gets an `ingress.enabled: true` first** | without a second value path there is nothing for the mutual-exclusion abort to name, and the Ingress plus its unconditional `required()` on `clusterIssuer` would keep rendering in Gateway mode — a value demanded for nothing. `paperless-ngx` was the last such chart and got the switch with group 2; the default keeps the render identical. |
|
||||
| **`NOTES.txt` names the active mode** and what it costs, as `keycloak` already does for its cases | a mode nobody can see from the outside |
|
||||
|
||||
**What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy. The five charts that set one (`bulwark`, `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`) must print in their Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway, because whoever overlooks that silently inherits the Gateway's default and meets it as a proxy error instead of an application error. The same holds for auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all — `cyberchef` is the live example.
|
||||
**What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy. The five charts that set one (`bulwark`, `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`) must print in their Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway, because whoever overlooks that silently inherits the Gateway's default and meets it as a proxy error instead of an application error. All but `bulwark` carry that disclosure since group 2, each in the chart's own terms rather than in one copied sentence. The same holds for auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all — `cyberchef` is the live example.
|
||||
|
||||
**And the CI side is part of the convention, not an afterthought.** Either the chart's install-test fixture installs in Gateway mode, or the chart gets a render-only overlay at `ci/httproute/<chart>.yaml`. Without one of the two, `ci/run-httproute-match-test.sh` is green over nothing for that chart — which that test fails on. See [CI/CD](CI-CD#5-httproute-match-every-chart-every-pr).
|
||||
**And the screen is "needs one", not "sets one".** `garage` sets `annotations: {}` and still depends on an upload limit, because ingress-nginx truncates S3 uploads at 1 MB; `freesailarr` sets none and needs none itself, but `ingress.annotations` is the only lever its operator has for one. Both say so in their Gateway-mode notes. Read values comments and `NOTES.txt`, not only templates.
|
||||
|
||||
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. The remaining 20 charts follow in their own PRs, with the five special cases (`matrix-synapse` renders in a loop, `keycloak` and `bulwark` have two Ingresses each, `homeassistant` is the documented ingress exception, `garage` ships `ingress.enabled: false`, `stalwart` renders per node group) argued one at a time.
|
||||
**`rewrite-target` is the one place where this mode is *less* machinery, not a translation of the old one.** Per object versus per rule: the shared-host shape above needs a second Ingress object for a prefix-stripping app, which then repeats a TLS secret name, omits the cert-manager annotation conditionally, and matches an `ImplementationSpecific` regex path. In Gateway mode it is one rule with a filter — and the four crutches are gone. Measured on `freesailarr`.
|
||||
|
||||
**A value derived from `ingress.enabled` is the sweep's second screen, and it is invisible in the Ingress template.** Gateway mode sets `ingress.enabled: false` by construction, so anything computed from it flips. Where that value stood for "is TLS terminated in front" or "is this app published at all", the flipped value is wrong and nothing fails. The fix is one helper reading `ingress.enabled OR httpRoute.enabled` — never a new value. Found twice so far: `sure` (`RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL`) and `freesailarr` (the shared-host app list, which also feeds the `<APP>__SERVER__URLBASE` variables). See [Lessons learned](Lessons-Learned).
|
||||
|
||||
**And the CI side is part of the convention, not an afterthought.** Either the chart's install-test fixture installs in Gateway mode, or the chart gets a render-only overlay at `ci/httproute/<chart>.yaml`. Without one of the two, `ci/run-httproute-match-test.sh` is green over nothing for that chart — which that test fails on. For a multi-app chart the overlay must also enter **every** shape the template has, or the branch nobody exercised is the one that breaks. See [CI/CD](CI-CD#5-httproute-match-every-chart-every-pr).
|
||||
|
||||
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Done so far: the pilot (`cyberchef`), group 1 — one Ingress, no behaviour-bearing annotation — (`bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`) and group 2 — the annotation cases — (`calibre-web-automated`, `outline`, `paperless-ngx`, `zipline`, `freesailarr`). Remaining: group 3, the special shapes (`matrix-synapse` renders in a loop, `keycloak` and `bulwark` have two Ingresses each, `homeassistant` is the documented ingress exception, `stalwart` renders per node group, `ytdlp-web-player` hangs off the oauth2-proxy subchart), plus `rss-bridge`, which is frozen for changes — which is why #114 cannot be closed while #51 is open.
|
||||
|
||||
## 5. Security hardening
|
||||
|
||||
@@ -169,6 +176,11 @@ Before putting an Ingress behind `ingress.enabled`, check whether anything else
|
||||
|
||||
This happened in six charts, and matrix-synapse shows why it matters more than the count: its delegation nginx serves `ingress.domain` in the `/.well-known/matrix/{server,client}` endpoints through which **federation finds the homeserver**. Gating without the guard publishes `{"m.server": ":443"}` — rendered cleanly, pod green, federation broken. Check per chart and in both directions: in `calibre-web-automated` nothing outside the Ingress reads the domain, and there the gate frees both values.
|
||||
|
||||
Two addenda from the Gateway-API sweep, because the second routing mode added a second reader and a second gate:
|
||||
|
||||
- **`ingress.domain` has a new reader in every converted chart**: the `HTTPRoute`, whenever `httpRoute.hostnames` is empty. A chart that *documented* the domain as Ingress-only therefore has to correct that sentence rather than leave it standing — `garage` and `calibre-web-automated` both did, and in both the claim now holds only while **both** modes are off.
|
||||
- **A read that relied on another template's `required()` is not guarded.** `paperless-ngx` built `PAPERLESS_URL` from `ingress.domain` without a `required()` of its own, which was safe only while the Ingress was unconditional. The moment the Ingress became switchable, `httpRoute.hostnames` set plus an empty `domain` would have rendered `https://` and refused every login POST as a CSRF failure. Guard the read where it is, not where it happens to be checked today.
|
||||
|
||||
### 2. Every chart-owned block goes through the null-safe helper, never behind an `if`
|
||||
|
||||
An override written without children is YAML null and **erases** the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits.
|
||||
|
||||
+16
-2
@@ -522,9 +522,23 @@ Nothing about that fails. The pod becomes `Ready`, the UI works, the route is `A
|
||||
|
||||
Three things worth carrying forward, and the first is the one that nearly got missed:
|
||||
|
||||
- **A chart's second routing mode is not a template change, it is a change to what `ingress.enabled` means** — and the readers that are affected are the ones **outside** the Ingress template, where nobody reviewing a routing PR is looking. The grep that finds them is `.Values.ingress.enabled` across the chart, *minus* the Ingress template and `NOTES.txt`. Still open on that count when group 1 landed: [freesailarr](freesailarr) and [ytdlp-web-player](ytdlp-web-player), both of which read it in their helpers.
|
||||
- **A chart's second routing mode is not a template change, it is a change to what `ingress.enabled` means** — and the readers that are affected are the ones **outside** the Ingress template, where nobody reviewing a routing PR is looking. The grep that finds them is `.Values.ingress.enabled` across the chart, *minus* the Ingress template and `NOTES.txt`. Still open on that count when group 1 landed: [freesailarr](freesailarr) and [ytdlp-web-player](ytdlp-web-player), both of which read it in their helpers. **Group 2 confirmed the prediction on freesailarr, and the symptom was a different one** — see below. `ytdlp-web-player` remains open.
|
||||
- **"Sets no behaviour-bearing annotation" is not the same question as "needs none."** Group 1's selection criterion screened on annotations a chart *sets*, measured and correct — and [garage](garage) passed it with `annotations: {}` while its own values comment tells the operator to set `proxy-body-size: "0"`, because ingress-nginx truncates S3 uploads at 1 MB. `proxy-body-size` has no Gateway-API counterpart at all, so garage is a sixth chart that loses an upload limit in Gateway mode where the issue had counted five. A screen over what a chart *declares* misses what a chart *documents*.
|
||||
- **A claim about a value's only reader expires when a second template is added.** garage's Ingress template stated, under the guideline that exists for exactly this, that nothing outside it read `ingress.domain` — true when written, false the moment an `HTTPRoute` read it as its hostname. The same sentence existed in its `values.schema.json`. **A "checked in both directions" note is a measurement with a date on it**, and the work that invalidates it is the work that has to correct it.
|
||||
- **A claim about a value's only reader expires when a second template is added.** garage's Ingress template stated, under the guideline that exists for exactly this, that nothing outside it read `ingress.domain` — true when written, false the moment an `HTTPRoute` read it as its hostname. The same sentence existed in its `values.schema.json`. **A "checked in both directions" note is a measurement with a date on it**, and the work that invalidates it is the work that has to correct it. [calibre-web-automated](calibre-web-automated) carried the identical claim and needed the identical correction in group 2 — the pattern repeats because the sentence is copied.
|
||||
|
||||
### `ingress.enabled` meant "is anything published", and that broke a sub-path, not a cookie
|
||||
|
||||
**Where:** [freesailarr](freesailarr), group 2 of the Gateway-API sweep (Gitea #114) — the chart the note above had flagged as still open.
|
||||
|
||||
**What it was:** the chart's helper for "which apps are served under the shared host, and at which path" was gated on `ingress.enabled`. That list has three consumers, and only two of them are routing objects: the Ingresses, `NOTES.txt`, and the `<APP>__SERVER__URLBASE` environment variables the chart sets for radarr, sonarr and prowlarr so that those apps know their own base path.
|
||||
|
||||
In Gateway mode `ingress.enabled` is `false` by construction, so the list would have come out **empty** while the `HTTPRoute` served the apps under `/radarr`, `/sonarr` and `/prowlarr`. Each app would then have answered at its sub-path with a page whose assets and API calls are linked at the **host root**: the page loads, the browser fetches nothing that exists, and the pod is `Ready` with zero restarts. The two render guards that live in the same helper — seerr and mediathekarr can only be served at `/`, and no two apps may claim the same path — would have gone unenforced in that mode for the same reason, so a configuration the chart is supposed to *refuse* would have rendered quietly.
|
||||
|
||||
Measured before and after, which is what distinguishes this from an argument: `helm template` on `origin/main` with `ingress.enabled: false` emits **zero** `URLBASE` variables; the fixed chart in Gateway mode emits three, with the right paths.
|
||||
|
||||
**Now:** one helper, `freesailarr.published` = `ingress.enabled` **OR** `httpRoute.enabled`, and the list asks that instead. Same shape as `sure.publishedOverTls`, same reason it stayed **minor**: with `httpRoute.enabled` at its default the expression *is* `ingress.enabled`.
|
||||
|
||||
**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 recurring shape
|
||||
|
||||
|
||||
+63
-9
@@ -8,7 +8,7 @@ Deploys [Calibre-Web Automated](https://github.com/crocodilestick/Calibre-Web-Au
|
||||
| Versions | not listed on purpose — read the current pair off `calibre-web-automated/Chart.yaml`, see [Chart index](Charts) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC (`cwa.storage`, mounted three times by `subPath`) + one **RWX** consume PVC fixed at `1Gi` |
|
||||
| Ingress | optional; `ingress.domain` + `ingress.clusterIssuer` required only while enabled |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`) — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). The upload cap is **not enforced** in Gateway mode |
|
||||
| External dependencies | none |
|
||||
| Secrets | 1 × `OnePasswordItem` (two fields) |
|
||||
| Install-test | `ci/calibre-web-automated/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
|
||||
@@ -17,7 +17,7 @@ Deploys [Calibre-Web Automated](https://github.com/crocodilestick/Calibre-Web-Au
|
||||
|
||||
- **1Password operator** in the cluster, plus one 1Password item — see [the fields below](#what-the-1password-item-must-contain).
|
||||
- **A storage class that can provision ReadWriteMany.** Both PVCs share `cwa.storageClass`, and the ingest claim is RWX. The default is `longhorn`; on a cluster without it, set the value — a class that cannot serve RWX leaves the consume claim `Pending` and the pod with it.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller — only if the Ingress stays enabled.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller — only if the Ingress stays enabled. In **Gateway mode** neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace.
|
||||
- **Time for the first boot.** Against an empty volume the app builds a Calibre library from scratch while the s6 init fixes ownership of `/config`; that first start takes minutes where every later one takes seconds.
|
||||
|
||||
## Installation
|
||||
@@ -48,6 +48,22 @@ ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
The same installation in **Gateway mode** instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
domain: books.example.com # the HTTPRoute's hostname, see below
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
```
|
||||
|
||||
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; `ingress.clusterIssuer` is not used. **And `cwa.maxUploadFileSize` stops being enforced** — for an e-book library that is the one thing to check before switching. See [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
**The default login is public.** Username `admin`, password `admin123` — upstream's defaults, which this chart does not change and the 1Password item does not carry. Until it is changed under *Admin → Users*, everyone who reaches the address is one guess away from being administrator.
|
||||
@@ -85,7 +101,7 @@ The chart renders one `OnePasswordItem`; the operator turns it into a Kubernetes
|
||||
| `cwa.storage` | string | `10Gi` | Size of the library PVC `<chart>-<release>-cwa-pvc`. Deliberately **not** null-safe: an erased value yields an invalid PVC rather than a silently degraded workload. |
|
||||
| `cwa.storageClass` | string | `longhorn` | Storage class of **both** PVCs. The consume claim is RWX, so the class has to serve that mode. |
|
||||
| `cwa.timezone` | string | `"UTC"` | Passed to the image as `TZ`; decides log timestamps and when CWA's scheduled tasks run. |
|
||||
| `cwa.maxUploadFileSize` | string | `"10m"` | Rendered into the `nginx.ingress.kubernetes.io/proxy-body-size` and `nginx.org/client-max-body-size` annotations. It is an **Ingress** limit, not an application one: a bigger book is rejected with 413 by the controller and nothing about it reaches the app log. Both annotations are understood by nginx-family controllers only, and the value does nothing at all while `ingress.enabled` is `false`. |
|
||||
| `cwa.maxUploadFileSize` | string | `"10m"` | Rendered into the `nginx.ingress.kubernetes.io/proxy-body-size` and `nginx.org/client-max-body-size` annotations. It is an **Ingress** limit, not an application one: a bigger book is rejected with 413 by the controller and nothing about it reaches the app log. Both annotations are understood by nginx-family controllers only, the value does nothing at all while `ingress.enabled` is `false`, and it is **not enforced in Gateway mode** either — see [Routing](#routing-ingress-or-gateway-api). |
|
||||
| `cwa.livenessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | See [What a Ready pod does not prove](#what-a-ready-pod-does-not-prove). |
|
||||
| `cwa.readinessProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 3 failures | |
|
||||
| `cwa.startupProbe` | object | `GET /` on port `http`, period 10s, timeout 5s, 30 failures (= 300s) | Gates liveness and readiness and is what times the first boot. On a slow volume the container is killed mid-scaffold and presents as `CrashLoopBackOff` — raise `failureThreshold` and watch the boot. |
|
||||
@@ -104,16 +120,52 @@ There is no value for the size of the ingest claim: it is fixed at `1Gi` inside
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. `ingress.domain` is read **nowhere else** in this chart, so switching the Ingress off makes both `domain` and `clusterIssuer` unnecessary. |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host and TLS subject. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. |
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. **Mutually exclusive with `httpRoute.enabled`.** Nothing inside the pod reads `ingress.domain`, so switching the Ingress off makes both `domain` and `clusterIssuer` unnecessary — **but only while the Gateway mode is off as well**: the `HTTPRoute` is a second reader of `domain` whenever `httpRoute.hostnames` is empty. |
|
||||
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true**, and again in Gateway mode with empty `hostnames`. Host and TLS subject. Read by the two routing templates and by nothing else in this chart — which is what makes a second hostname cheap here (see [Routing](#routing-ingress-or-gateway-api)). |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. Unused *and* unnecessary in Gateway mode. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so these are gone there — including the chart's own upload cap. |
|
||||
|
||||
TLS is stored in `tls-<release>-calibre-web-automated-ingress`. Without an Ingress the app is reachable in-cluster as `<release>-calibre-web-automated-service:80`, or locally via `kubectl -n <namespace> port-forward svc/<release>-calibre-web-automated-service 8083:80`.
|
||||
TLS is stored in `tls-<release>-calibre-web-automated-ingress`. With neither routing mode on, the app is reachable in-cluster as `<release>-calibre-web-automated-service:80`, or locally via `kubectl -n <namespace> port-forward svc/<release>-calibre-web-automated-service 8083:80`.
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `httpRoute.enabled` | bool | `false` | Renders `<release>-calibre-web-automated-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values. |
|
||||
| `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 and adds fields to it across releases. |
|
||||
| `httpRoute.hostnames` | list | `[]` | Empty means `[ingress.domain]`, so the hostname has a single source of truth. Set it explicitly only to publish under a different or an additional name. Nothing inside the pod follows the hostname in this chart, so a name set here needs no second value changed with it. |
|
||||
|
||||
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-calibre-web-automated-service` on port **80** — the port **number**, because `backendRefs` cannot name a port the way the Ingress backend does.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Routing: Ingress or Gateway API
|
||||
|
||||
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`.
|
||||
|
||||
| Rule | Why it is a hard failure and not a default |
|
||||
|---|---|
|
||||
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto one Service under one hostname 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** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
|
||||
| `httpRoute.parentRefs` must not be empty — the render aborts | 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. |
|
||||
| the mode is a **value**, never auto-detected | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
|
||||
**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, which live in `cluster-config`. `ingress.clusterIssuer` is unused **and** unnecessary in this mode.
|
||||
|
||||
**Your upload limit becomes the Gateway's, and this is a library.** `cwa.maxUploadFileSize` exists only as an ingress-nginx annotation, and the Gateway API has **no** counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. So in Gateway mode the cap is whatever the Gateway enforces: a book above it is rejected before CWA sees a byte, the reader gets the proxy's error rather than a message from the application, and nothing anywhere says that a chart value was ignored. Set the limit at the Gateway, or through its implementation's own policy object next to the Gateway.
|
||||
|
||||
**`ingress.domain` behaves differently here than in most charts, and in your favour.** Nothing inside the pod is told its public address — no environment variable, no probe `Host` header, no URL CWA builds itself. So `httpRoute.hostnames` can be set to a second name without a single other value moving with it, which is exactly what a migration off the Ingress needs. The flip side is the claim that had to be corrected: `values.yaml` and the schema used to say `ingress.domain` is read nowhere but in the Ingress template, which stopped being true when the route became its second reader.
|
||||
|
||||
**Whether the route attached at all is only visible in its status**, because no Kubernetes object reports a route that attaches to nothing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get httproute <release>-calibre-web-automated-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.
|
||||
|
||||
### The hardening here is deliberately not the repo standard
|
||||
|
||||
The image boots through the s6-overlay init as **root** and only then drops the application to `PUID`/`PGID` 1000. Hence no `runAsNonRoot`/`runAsUser`, a writable root filesystem, and a `capabilities.add` that is not empty. Two of those look like oversights and are not:
|
||||
@@ -181,6 +233,8 @@ kubectl -n <namespace> logs <release>-calibre-web-automated-sfs-0 -f
|
||||
|
||||
`ci/calibre-web-automated/` holds `test-values.yaml` (`op://` ref against the stub CRD, host `cwa.ci.local`, `storageClass: ci-static`, `storage: 2Gi`), `fixtures.yaml` (the dummy secret with both fields plus a no-provisioner StorageClass and two pre-bound static hostPath PVs — k3d's `local-path` provisioner cannot serve the RWX consume claim, and the PVs' `claimRef`s carry the historical PVC names) and `settings.env` with `INSTALL_TIMEOUT=600` for the library scaffold on first start. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
**The install-test stays in Ingress mode; the route is held at render level.** The install runs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. [cyberchef](cyberchef) is the chart that pays that price and installs in Gateway mode against the **real** Gateway-API CRDs, which is where a malformed `parentRefs` or `backendRefs` is caught. Instead `ci/httproute/calibre-web-automated.yaml` is a **render-only overlay** that switches this chart into Gateway mode for `ci/run-httproute-match-test.sh`, which asserts that every `backendRefs` entry names a Service **and a port** the same render creates and that `parentRefs` is not empty. A chart that has an overlay and renders no route **fails** that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and §12.4, whose worked example it is
|
||||
|
||||
+76
-12
@@ -7,7 +7,7 @@ Deploys a Wireguard-tunneled media automation stack as a **single StatefulSet po
|
||||
| Versions | not listed on purpose — read the current pair off `freesailarr/Chart.yaml`, see [Chart index](Charts). The app version is a hand-maintained marker and **not** the source of any image tag (see [Ten upstreams, no app version](#ten-upstreams-no-app-version)) |
|
||||
| Workloads | one StatefulSet, 1 replica, 10 containers (9 by default) + one init container |
|
||||
| Storage | one RWO `volumeClaimTemplate` per enabled app (config, 1Gi each) + up to three **pre-existing** media claims |
|
||||
| Ingress | two shapes that mix: one host per app (default) or one shared `ingress.host` with sub-paths. There is **no** `ingress.domain` |
|
||||
| Routing | **two modes** (Ingress, default, or Gateway API via `httpRoute.enabled`) × **two shapes that mix** (one host per app, default, or one shared `ingress.host` with sub-paths). There is **no** `ingress.domain` and therefore no `httpRoute.hostnames` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
|
||||
| External dependencies | a VPN account (Mullvad Wireguard by default), the media PVCs, optionally a Jellyfin for seerr |
|
||||
| Secrets | 1 × `OnePasswordItem` (gluetun credentials) |
|
||||
| Install-test | `ci/freesailarr/` — the only chart in the repo with a `fixtures.env.yaml` and a real tunnel |
|
||||
@@ -19,7 +19,7 @@ Deploys a Wireguard-tunneled media automation stack as a **single StatefulSet po
|
||||
- **`/dev/net/tun` on the node.** gluetun mounts it as a `hostPath` CharDevice and runs as root with `NET_ADMIN`; a cluster policy that forbids either makes the pod unschedulable.
|
||||
- **The media claims exist already.** `media.<category>.existingClaim` is mounted, never created, and the claim's **root directory must be writable for `puid`/`pgid`** before the first start — see [The claim root, not fsGroup](#the-claim-root-not-fsgroup).
|
||||
- **The cluster pod and service CIDRs**, for `gluetun.vpn.firewallOutboundSubnets`. Without them the kill switch also blocks cluster-internal egress, starting with DNS.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for every host used, pointing at the ingress controller.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for every host used, pointing at the ingress controller. In **Gateway mode** neither is needed from this release: the certificates sit on the Gateway's listener, and what is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -54,6 +54,23 @@ ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
The same installation in **Gateway mode** instead — the Gateway, its class and its certificates belong to the cluster, not to this release. `ingress.enabled` is this chart's master switch and defaults to `true`, so it has to be switched off explicitly or the render aborts:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
host: media.example.com # still the shared hostname: both modes read it
|
||||
# clusterIssuer / className / annotations are inert in this mode
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
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).
|
||||
|
||||
`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.
|
||||
|
||||
### What the 1Password item must contain
|
||||
@@ -110,13 +127,32 @@ Defaults for all apps; `clusterIssuer`, `className` and `annotations` are overri
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Master switch: `false` suppresses every app Ingress regardless of the per-app toggles. |
|
||||
| `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. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required for every Ingress object that renders** and is not overridden by an `<app>.ingress.clusterIssuer`. cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. The shared-domain qBittorrent object relies on nginx's `rewrite-target`. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. |
|
||||
| `ingress.enabled` | bool | `true` | Master switch: `false` suppresses every app Ingress regardless of the per-app toggles. **Mutually exclusive with `httpRoute.enabled`**, and since it defaults to `true` it must be switched off explicitly to use the Gateway mode. |
|
||||
| `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 `<app>.ingress.host`. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required for every Ingress object that renders** and is not overridden by an `<app>.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. |
|
||||
|
||||
TLS secrets: `tls-<release>-freesailarr-<app>-ingress` per own-host object, `tls-<release>-freesailarr-ingress` for the shared one.
|
||||
TLS secrets: `tls-<release>-freesailarr-<app>-ingress` per own-host object, `tls-<release>-freesailarr-ingress` for the shared one. In Gateway mode there are none — the certificates are the Gateway listener's.
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)), with one documented deviation — see the note under the table.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `httpRoute.enabled` | bool | `false` | Renders `HTTPRoute`s instead of Ingresses. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values, and `ingress.enabled` defaults to `true` here. |
|
||||
| `httpRoute.parentRefs` | list | `[]` | The Gateway(s) **every** route of this release 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 hosts differ per app; the Gateway does not. |
|
||||
|
||||
**There is deliberately no `httpRoute.hostnames` here — this is the only chart without it.** It has no `ingress.domain` either, for the same reason: ten independently exposed apps, so there is no single hostname a chart-global list could belong to. The hostnames stay in `ingress.host` and `<app>.ingress.host`, which is where this chart's single source of truth for them already is. A migration therefore runs by changing those values; in Gateway mode no Ingress renders that could disagree with them.
|
||||
|
||||
What the chart renders in this mode:
|
||||
|
||||
- **Shape 1 (own host):** one `HTTPRoute` per app, `<release>-freesailarr-<app>-httproute`, hostname `<app>.ingress.host`, one rule at `PathPrefix: /` pointing at that app's Service.
|
||||
- **Shape 2 (shared host):** **exactly one** `HTTPRoute`, `<release>-freesailarr-httproute`, hostname `ingress.host`, one rule per app at its `<app>.ingress.path` — qBittorrent included, with its `URLRewrite` filter on that one rule.
|
||||
- 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.
|
||||
|
||||
### Per-app keys
|
||||
|
||||
@@ -213,21 +249,47 @@ kubectl -n <namespace> logs <release>-freesailarr-sfs-0 -c gluetun -f
|
||||
|
||||
Liveness is more tolerant (60s) than readiness (30s) on purpose: during a short re-negotiation gluetun recovers by itself and a kubelet restart would only prolong the outage, while the kill switch keeps blocking all egress the whole time.
|
||||
|
||||
### Two ingress shapes, and they mix
|
||||
### Routing: Ingress or Gateway API
|
||||
|
||||
1. **One host per app** (default): each app with `<app>.ingress.enabled` gets its own object `<release>-freesailarr-<app>-ingress`, its own host and its own certificate. The hosts need not share a domain.
|
||||
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`. Both shapes below work in both modes; the per-app `<app>.ingress.enabled` toggle selects which apps are published either way.
|
||||
|
||||
| Rule | Why it is a hard failure and not a default |
|
||||
|---|---|
|
||||
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto the same Services under the same hostnames is a conflict at DNS level already. The abort message names the `true` default explicitly, because setting `httpRoute.enabled` alone is the likely mistake here. A migration runs by changing `ingress.host` or `<app>.ingress.host`, never in parallel. |
|
||||
| `httpRoute.parentRefs` must not be empty — the render aborts | 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. |
|
||||
| the mode is a **value**, never auto-detected | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the CRDs fails at install time. |
|
||||
|
||||
**What moves to the platform.** In Gateway mode the chart renders no `Gateway`, no `gatewayClassName`, no `Certificate` and — deliberately — no implementation-specific policy object. Only `HTTPRoute` with `parentRefs` looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio. `ingress.clusterIssuer`, `ingress.className`, `ingress.annotations` and their per-app overrides are all inert there.
|
||||
|
||||
**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.
|
||||
|
||||
**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.** `<APP>__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).
|
||||
|
||||
**Whether a route attached at all is only visible in its status**, because no Kubernetes object reports a route that attaches to nothing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> 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.
|
||||
|
||||
### Two routing shapes, and they mix
|
||||
|
||||
1. **One host per app** (default): each app with `<app>.ingress.enabled` gets its own object — `<release>-freesailarr-<app>-ingress` in Ingress mode, `<release>-freesailarr-<app>-httproute` in Gateway mode — its own host and, in Ingress mode, its own certificate. The hosts need not share a domain.
|
||||
2. **One shared domain**: set `ingress.host`, leave the per-app hosts empty. Every such app moves into **one** object as a sub-path — one DNS name, one certificate.
|
||||
|
||||
A per-app host always wins, so a mixed setup needs no extra values. The cost per app in sub-path mode, measured rather than assumed:
|
||||
|
||||
- **radarr, sonarr, prowlarr — nothing.** The chart sets `<APP>__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/<asset>` 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 — 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). Two upstream traps, both of which 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 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`.
|
||||
- **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.
|
||||
|
||||
### 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 an ingress.
|
||||
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.
|
||||
|
||||
### One pod, not a stack of pods
|
||||
|
||||
@@ -370,6 +432,8 @@ Renovate reaches every image through the built-in `helm-values` manager, which r
|
||||
|
||||
The key in 1Password is a **CI-only** Mullvad key, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would let every PR pull the tunnel out from under the running stack. The install-test runs on every PR that touches the chart and takes ~225 s alone — see [CI/CD](CI-CD).
|
||||
|
||||
**The install-test stays in Ingress mode; the routes are held at render level.** The install runs exactly one configuration per chart, and this chart's is the expensive one — a real tunnel so that ten containers can become Ready. Switching it would trade that for a CRD schema [cyberchef](cyberchef) already exercises against the **real** Gateway-API CRDs. Instead `ci/httproute/freesailarr.yaml` is a **render-only overlay** for `ci/run-httproute-match-test.sh`, and for a multi-app chart that overlay has a second job: it must enter **every** shape, or the branch nobody exercised is the one that breaks. It therefore moves **bazarr onto a host of its own** on top of the fixture's shared `ingress.host`, so the shared-host route *and* a per-app route both render — eight assertions from this chart alone, including qBittorrent's rule with its `URLRewrite` filter and mediathekarr's ingress staying off, which exercises the per-app selector in the negative direction. Neither half proves that traffic flows: no Gateway controller runs in CI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the four it is a documented exception to
|
||||
|
||||
+65
-9
@@ -8,7 +8,7 @@ Deploys [Outline](https://www.getoutline.com/), a team knowledge base, as a sing
|
||||
| Versions | not listed on purpose — read the current pair off `outline/Chart.yaml`, see [Chart index](Charts) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC for uploads (`FILE_STORAGE=local`) |
|
||||
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`); `ingress.domain` is required in both — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). In Gateway mode the upload cap is **not enforced**, which moves [#12](#two-upload-ceilings-the-smaller-one-wins) |
|
||||
| External dependencies | Postgres, Redis, an OIDC provider |
|
||||
| Secrets | 3 × `OnePasswordItem` |
|
||||
| Install-test | `ci/outline/` (fixtures + test values) |
|
||||
@@ -17,7 +17,7 @@ Deploys [Outline](https://www.getoutline.com/), a team knowledge base, as a sing
|
||||
|
||||
- **1Password operator** in the cluster, plus three 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **Postgres and Redis reachable before the first start.** All three probes hit `/_health`, which checks both, so a missing database shows up as a pod that never turns Ready — read the container log, not the pod events.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. In **Gateway mode** neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace.
|
||||
- **An OIDC client** whose redirect URI is `https://<ingress.domain>/auth/oidc.callback`.
|
||||
|
||||
## Installation
|
||||
@@ -46,6 +46,22 @@ ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
The same installation in **Gateway mode** instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
domain: wiki.example.com # still required: it is Outline's URL variable
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
```
|
||||
|
||||
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; `ingress.clusterIssuer` is not used. `ingress.domain` is **still required** — it becomes Outline's `URL`, which builds every generated link, every share URL and the OIDC redirect URI. And `ingress.maxUploadFileSize` stops being enforced; see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
@@ -98,17 +114,53 @@ The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernet
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | Switches the Ingress object off. **`ingress.domain` stays required** — it is also Outline's `URL` variable. |
|
||||
| `ingress.domain` | string | `null` | **Required.** Host, and the base of every generated link, share URL and the OIDC redirect URI. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.maxUploadFileSize` | string | `25m` | `proxy-body-size` annotation. See [Two upload ceilings](#two-upload-ceilings-the-smaller-one-wins). |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. |
|
||||
| `ingress.enabled` | bool | `true` | Switches the Ingress object off. **Mutually exclusive with `httpRoute.enabled`.** **`ingress.domain` stays required** — it is also Outline's `URL` variable and carries its own `required()` in the StatefulSet. |
|
||||
| `ingress.domain` | string | `null` | **Required in every mode.** Host, and the base of every generated link, share URL and the OIDC redirect URI. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. Unused *and* unnecessary in Gateway mode. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
|
||||
| `ingress.maxUploadFileSize` | string | `25m` | `proxy-body-size` annotation. See [Two upload ceilings](#two-upload-ceilings-the-smaller-one-wins). **Not enforced in Gateway mode.** |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
|
||||
|
||||
TLS is stored in `tls-<release>-outline-ingress`.
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `httpRoute.enabled` | bool | `false` | Renders `<release>-outline-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values. |
|
||||
| `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 and adds fields to it across releases. |
|
||||
| `httpRoute.hostnames` | list | `[]` | Empty means `[ingress.domain]`, so the hostname has a single source of truth. A hostname set here does **not** move Outline's `URL` with it — set both together, or the app hands out links under the old name and an OIDC login fails on a `redirect_uri` the provider does not know. |
|
||||
|
||||
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-outline-service` on port **80** — the port **number**, because `backendRefs` cannot name a port the way the Ingress backend does. One path covers UI, API and the collaborative WebSocket; the upgrade passes through a plain route, with no annotation involved in either mode.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Routing: Ingress or Gateway API
|
||||
|
||||
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`.
|
||||
|
||||
| Rule | Why it is a hard failure and not a default |
|
||||
|---|---|
|
||||
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto one Service under one hostname 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** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
|
||||
| `httpRoute.parentRefs` must not be empty — the render aborts | 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. |
|
||||
| the mode is a **value**, never auto-detected | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
|
||||
**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, which live in `cluster-config`. `ingress.clusterIssuer` is unused **and** unnecessary in this mode.
|
||||
|
||||
**`ingress.domain` is required in this mode too, and not because of the route.** It becomes Outline's `URL`, with its own `required()` in the StatefulSet — every generated link, every share URL and the OIDC redirect URI come from it. Set `httpRoute.hostnames` to a name `ingress.domain` does not carry and the route publishes one host while the app advertises another: pages load, and the OIDC login fails on a `redirect_uri` the provider does not know.
|
||||
|
||||
**The upload ceiling becomes the Gateway's, which is why this mode *moves* the open defect rather than resolving it.** `ingress.maxUploadFileSize` exists only as an ingress-nginx annotation, and the Gateway API has **no** counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. In Gateway mode the ceiling that actually bites is therefore the smaller of `outline.maxUploadSize` and *whatever the Gateway enforces*, and the chart can only name the first of the two. See [Two upload ceilings](#two-upload-ceilings-the-smaller-one-wins) and check the Gateway's own limit before trusting either number.
|
||||
|
||||
**Whether the route attached at all is only visible in its status**, because no Kubernetes object reports a route that attaches to nothing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get httproute <release>-outline-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 all three probes share `/_health`
|
||||
|
||||
`/_health` checks Postgres **and** Redis. Normally liveness must not hang on an external dependency ([style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie)), but Outline returns 500 on `/` as well when the database is down, so there is no dependency-free path to probe. The reason is a comment on the helper — do not "fix" the inconsistency.
|
||||
@@ -119,7 +171,9 @@ Uploads live on a **ReadWriteOnce** PVC that is a plain chart object, not a `vol
|
||||
|
||||
### Two upload ceilings, the smaller one wins
|
||||
|
||||
`outline.maxUploadSize` (bytes, default ~250 MiB) and `ingress.maxUploadFileSize` (default `25m`). With the defaults the Ingress is tighter by a factor of ten, and it fails the more confusingly: the file never reaches Outline, so the user sees the ingress controller's 413 page rather than a message from the app. Raise both, or neither.
|
||||
`outline.maxUploadSize` (bytes, default ~250 MiB) and `ingress.maxUploadFileSize` (default `25m`). With the defaults the Ingress is tighter by a factor of ten, and it fails the more confusingly: the file never reaches Outline, so the user sees the ingress controller's 413 page rather than a message from the app. Raise both, or neither. This is Gitea **#12**, still open.
|
||||
|
||||
**Gateway mode changes the shape of that defect without fixing it.** The ingress half of the pair disappears — the Gateway API has no body-size field, so `ingress.maxUploadFileSize` is simply not enforced — and the effective ceiling becomes the smaller of `outline.maxUploadSize` and whatever the **Gateway** enforces. Neither of the two numbers this chart documents is then necessarily the one that decides, which is worse for diagnosis than the factor of ten: at least both of those are in the values file. Rebuilding the limit in the chart would mean rendering an implementation-specific policy object, which is the portability the Gateway mode exists to buy. See [Routing](#routing-ingress-or-gateway-api).
|
||||
|
||||
### Rotating a secret does not restart the pod
|
||||
|
||||
@@ -150,6 +204,8 @@ Pinned next to the connection URL. That fits a Postgres reached over the cluster
|
||||
|
||||
`ci/outline/` holds `test-values.yaml` (op:// refs against the stub CRD, `outline.ci.local`) and `fixtures.yaml` (throwaway Postgres and Redis plus the dummy secrets). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
**The install-test stays in Ingress mode; the route is held at render level.** The install runs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. [cyberchef](cyberchef) is the chart that pays that price and installs in Gateway mode against the **real** Gateway-API CRDs, which is where a malformed `parentRefs` or `backendRefs` is caught. Instead `ci/httproute/outline.yaml` is a **render-only overlay** that switches this chart into Gateway mode for `ci/run-httproute-match-test.sh`, which asserts that every `backendRefs` entry names a Service **and a port** the same render creates and that `parentRefs` is not empty. A chart that has an overlay and renders no route **fails** that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements
|
||||
|
||||
+67
-6
@@ -8,7 +8,7 @@ Deploys [Paperless-ngx](https://docs.paperless-ngx.com/), a document management
|
||||
| Versions | not listed on purpose — read the current pair off `paperless-ngx/Chart.yaml`, see [Chart index](Charts) |
|
||||
| Workloads | StatefulSet (1 replica) with a root init container; optional consume CronJob; plus the subchart's own StatefulSet when it is on |
|
||||
| Storage | main PVC (RWO, `paperless.files.storage`) + consume PVC (RWX, **hardcoded 1Gi**); both on the hardcoded storage class `longhorn`. The subchart adds its own PVC on its own class |
|
||||
| Ingress | yes, **always rendered** — this chart has no `ingress.enabled` |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`). `ingress.enabled` exists since group 2 of the Gateway-API sweep and defaults to `true`; before that the Ingress was **always** rendered — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
|
||||
| External dependencies | Postgres; Redis **unless the subchart provides it**; optionally Tika + Gotenberg, an OIDC provider, the `smb.csi.k8s.io` driver |
|
||||
| Subchart | optional `valkey` — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled`. Configured as a **queue**, the opposite of [stalwart](stalwart)'s cache |
|
||||
| Secrets | 3 × `OnePasswordItem`, a 4th with SMB enabled, a 5th rendered by the subchart (and it may name the same item as the Redis one) |
|
||||
@@ -20,7 +20,7 @@ Deploys [Paperless-ngx](https://docs.paperless-ngx.com/), a document management
|
||||
- **Postgres reachable before the first start**, and **Redis too unless you let the chart bring it**: either point `redis-connection-url` at an existing instance or set `valkeySubchart.enabled: true`, which deploys this repo's own [valkey](valkey) chart alongside. They fail in opposite ways and only one of them is loud — see [Redis fails quietly, Postgres does not](#redis-fails-quietly-postgres-does-not).
|
||||
- **With `valkeySubchart.enabled`:** a third field `auth-conf` in the Redis 1Password item, and the connection url repointed at the subchart's Service. Both are steps, not hints — see [the bundled Valkey](#the-bundled-valkey-one-step-of-it-is-in-the-vault).
|
||||
- **A storage class named `longhorn`** that can serve **ReadWriteMany** for the consume claim. Both PVCs hardcode it; there is no value to change it.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. In **Gateway mode** neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace.
|
||||
- **With `smbConnection.enabled`:** the `smb.csi.k8s.io` CSI driver, which this chart does not install.
|
||||
- **With `oidc.enabled`:** an OIDC client that completes the flow **without a client secret** (PKCE).
|
||||
|
||||
@@ -50,6 +50,22 @@ ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
The same installation in **Gateway mode** instead — the Gateway, its class and its certificate belong to the cluster, not to this release. Note that `ingress.enabled` defaults to `true` here, so it has to be switched off explicitly or the render aborts:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
domain: paperless.example.com # still required: it is PAPERLESS_URL
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
```
|
||||
|
||||
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; `ingress.clusterIssuer` is not used. `ingress.domain` is **still required** — it is also `PAPERLESS_URL`, the only origin the release declares to Django. And `paperless.files.maxUploadFileSize` stops being enforced for web uploads; the consume directory is unaffected. See [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
@@ -94,13 +110,26 @@ kubectl -n <namespace> exec -it <release>-paperless-ngx-sfs-0 -- \
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.domain` | string | `null` | **Required, always.** Ingress host *and* `PAPERLESS_URL` (`https://<domain>`), the only origin the release declares. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required, always** — the Ingress has no `enabled` gate to free it. cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. |
|
||||
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. **New with group 2 of the Gateway-API sweep** — before that this chart was the last one in the repository rendering its Ingress unconditionally. **Mutually exclusive with `httpRoute.enabled`**, and since it defaults to `true` it must be switched off explicitly to use the Gateway mode. |
|
||||
| `ingress.domain` | string | `null` | **Required, always and in every mode.** Ingress host *and* `PAPERLESS_URL` (`https://<domain>`), the only origin the release declares — the StatefulSet carries its own `required()` for it ([style guide §12.1](Chart-Style-Guide#12-values-that-must-not-lie)). Also the `HTTPRoute`'s hostname while `httpRoute.hostnames` is empty. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. It used to be required *always*, because there was no gate to free it; with the gate it is conditional, and in Gateway mode it is unused *and* unnecessary. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and the two body-size annotations the chart always sets. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
|
||||
|
||||
TLS is stored in `tls-<release>-paperless-ngx-ingress`.
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `httpRoute.enabled` | bool | `false` | Renders `<release>-paperless-ngx-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values, and `ingress.enabled` defaults to `true` here. |
|
||||
| `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 and adds fields to it across releases. |
|
||||
| `httpRoute.hostnames` | list | `[]` | Empty means `[ingress.domain]`, so the hostname has a single source of truth. A hostname set here does **not** move `PAPERLESS_URL` with it — Django keeps trusting the origin built from `ingress.domain`, and every login POST from the new name is refused as a CSRF failure until that value is changed too (and the pods restarted). |
|
||||
|
||||
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-paperless-ngx-service` on port **80** — the port **number**, where the Ingress references the same hop by name (`pless-s-port`). Three names describe that hop in this chart, which is why the number is held by `ci/run-httproute-match-test.sh` rather than by review.
|
||||
|
||||
### `paperless`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
@@ -226,6 +255,32 @@ The first draft of that check was a `fail()` in `_helpers.tpl` called from a ren
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Routing: Ingress or Gateway API
|
||||
|
||||
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`.
|
||||
|
||||
**This chart gained `ingress.enabled` with the Gateway mode, and the reason is worth knowing.** It was the last chart in the repository rendering its Ingress unconditionally. Without a second value path there is nothing for the mutual-exclusion abort to name — and worse, the Ingress together with its *unconditional* `required()` on `clusterIssuer` would have kept rendering in Gateway mode: a value demanded for something the chart does not produce, which is exactly the defect [§12](Chart-Style-Guide#12-values-that-must-not-lie) exists to remove. The switch defaults to `true`, so an existing release is unaffected and renders byte-identically; the practical consequence is only that Gateway mode needs `ingress.enabled: false` written out.
|
||||
|
||||
| Rule | Why it is a hard failure and not a default |
|
||||
|---|---|
|
||||
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto one Service under one hostname 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**, never in parallel. The abort message names the `true` default explicitly here, because setting `httpRoute.enabled` alone is the likely mistake. |
|
||||
| `httpRoute.parentRefs` must not be empty — the render aborts | 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. |
|
||||
| the mode is a **value**, never auto-detected | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
|
||||
**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, which live in `cluster-config`.
|
||||
|
||||
**`ingress.domain` is required in this mode too, and it now says so itself.** It is `PAPERLESS_URL`, the only origin this release declares to Django. That read used to rely on the Ingress template's `required()` firing first — which stopped being guaranteed the moment the Ingress became switchable, so the StatefulSet carries its own `required()` now. Without it, `httpRoute.hostnames` set and `ingress.domain` empty would have rendered `https://` and refused every login POST as a CSRF failure, with nothing in the log naming a chart value. Same consequence in the softer case: a hostname in `httpRoute.hostnames` that `ingress.domain` does not carry means pages load and logins fail. Set both together, and remember the pods restart before they notice a changed domain.
|
||||
|
||||
**The web-UI upload cap becomes the Gateway's; the consume directory is untouched.** `paperless.files.maxUploadFileSize` exists only as an ingress-nginx annotation, and the Gateway API has **no** counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. So in Gateway mode the web-UI cap is whatever the Gateway enforces, and the error comes from the proxy rather than from paperless. The second intake route is unaffected in every mode: the consume directory passes no proxy and has no size limit at all, so a document too large for the web UI still gets in that way.
|
||||
|
||||
**Whether the route attached at all is only visible in its status**, because no Kubernetes object reports a route that attaches to nothing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get httproute <release>-paperless-ngx-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.
|
||||
|
||||
### Redis fails quietly, Postgres does not
|
||||
|
||||
Without the database paperless does not come up at all — and all three probes ask for `/` on port 8000, so you get no useful error: the pod never turns Ready and the startup probe kills it after 600s. Read the container log, not the pod events.
|
||||
@@ -283,6 +338,8 @@ kubectl -n <namespace> cp <file.pdf> <release>-paperless-ngx-sfs-0:/usr/src/pape
|
||||
|
||||
Web uploads take the second route and pass the Ingress, where `paperless.files.maxUploadFileSize` (default `10m`) caps the body. A larger file is rejected by the ingress controller with a 413 before paperless ever sees it — the consume directory has no such limit.
|
||||
|
||||
**In Gateway mode that value is not enforced at all**: the Gateway API has no body-size field, so the cap is the Gateway's and the rejection is the Gateway's error. The consume directory is unaffected either way, which makes it the reliable route for a large document in both modes. See [Routing](#routing-ingress-or-gateway-api).
|
||||
|
||||
### The consume CronJob is the one probes exception
|
||||
|
||||
`smbConnection.enabled: true` adds `<release>-paperless-ngx-consume-cronjob`, which mounts the share and runs, once per firing:
|
||||
@@ -358,6 +415,8 @@ Nothing about OIDC is exercised before someone actually signs in, so a wrong dis
|
||||
|
||||
`ingress.domain` is also `PAPERLESS_URL`, the only origin the release declares. A port-forward loads pages over `http://localhost:8000`, but the login POST is refused as a CSRF failure. Forward the port to look around, use the domain to log in, or add `PAPERLESS_CSRF_TRUSTED_ORIGINS` through `paperless.env` for as long as needed. Changing `ingress.domain` later changes `PAPERLESS_URL` with it, and the pods have to restart before they notice.
|
||||
|
||||
The same mechanism is the reason `httpRoute.hostnames` must not disagree with `ingress.domain`: a route published under a name Django does not trust produces exactly this picture — pages load, logins fail — except that nothing about it looks like a port-forward. See [Routing](#routing-ingress-or-gateway-api).
|
||||
|
||||
## Upgrades and backup
|
||||
|
||||
- **Back up before every app-version bump, and back up both halves.** Postgres holds the metadata, the tags and one row per document; `<release>-paperless-ngx-volume-claim` holds the files under `data/`, `media/` and `export/`. Restoring one without the other gives an archive full of references to documents that are not there.
|
||||
@@ -372,6 +431,8 @@ Nothing about OIDC is exercised before someone actually signs in, so a wrong dis
|
||||
|
||||
**The subchart is ON in CI, and the throwaway Redis Deployment is gone** — this is the **first real installation of the valkey-subchart path** anywhere in this repository. [stalwart](stalwart) added the same dependency first but covered it by a local render only, deliberately: its in-memory store is a database object, so a Valkey in that namespace would have been reachable and unused and the test would have looked as though it covered clustering. Here it is the opposite — paperless reads `PAPERLESS_REDIS` from the vault item, so pointing that url at `ci-valkey-service` makes the bundled Valkey the broker the release actually uses. What the run proves beyond the previous one: the subchart's StatefulSet becomes Ready inside a parent release (so its init container found the `requirepass ` line and its three authenticated `valkey-cli` probes answered `PONG`), its PVC path works under a parent release name, and the names really are `<release>-valkey-<kind>`. A wrong Service name or a password mismatch between the item's two fields is a red run rather than a quietly idle Valkey.
|
||||
|
||||
**The install-test stays in Ingress mode, and here that was the easy call.** The install runs exactly one configuration per chart, and this chart's fixture is the only installation of the valkey-subchart path anywhere — switching it into Gateway mode would have traded that coverage, plus the Ingress, for a CRD schema [cyberchef](cyberchef) already exercises against the **real** Gateway-API CRDs. Instead `ci/httproute/paperless-ngx.yaml` is a **render-only overlay** that switches this chart into Gateway mode for `ci/run-httproute-match-test.sh`, which asserts that every `backendRefs` entry names a Service **and a port** the same render creates and that `parentRefs` is not empty. A chart that has an overlay and renders no route **fails** that test. Neither half proves that traffic flows: no Gateway controller runs in CI.
|
||||
|
||||
Both fixture Secrets carry **all three fields**, because in a real cluster both `OnePasswordItem`s name the same item and the operator materializes every field of it. Trimming each to what its consumer reads would have been a fixture tidier than reality, hiding the one thing worth knowing. The Valkey PVC uses `local-path` (k3d's provisioner): the chart's `longhorn-encrypted-ns` does not exist on the runner, and the pre-bound hostPath PVs of the `longhorn` stand-in would be worse, since the kubelet applies no `fsGroup` to a hostPath volume and the ownership path this test exists for would silently stop being exercised.
|
||||
|
||||
What it still does not prove: that documents are processed. That needs a real consume run and is out of scope here, as before.
|
||||
|
||||
+65
-9
@@ -8,7 +8,7 @@ Deploys [Zipline](https://zipline.diced.sh/), a file- and image-sharing server,
|
||||
| Versions | not listed on purpose — read the current pair off `zipline/Chart.yaml`, see [Chart index](Charts) |
|
||||
| Workloads | StatefulSet (1 replica) |
|
||||
| Storage | one RWO PVC, three `subPath` mounts (`uploads`, `public`, `themes`) + `/tmp` emptyDir |
|
||||
| Ingress | yes; `ingress.domain` required **always**, `ingress.clusterIssuer` only while the Ingress is enabled |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`); `ingress.domain` required **always**, `ingress.clusterIssuer` only while the Ingress is enabled — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). In Gateway mode the upload cap is **not enforced** |
|
||||
| External dependencies | PostgreSQL |
|
||||
| Secrets | 2 × `OnePasswordItem` |
|
||||
| Install-test | `ci/zipline/` (fixtures + test values) |
|
||||
@@ -17,7 +17,7 @@ Deploys [Zipline](https://zipline.diced.sh/), a file- and image-sharing server,
|
||||
|
||||
- **1Password operator** in the cluster, plus two 1Password items — see [the fields below](#what-the-1password-items-must-contain).
|
||||
- **A reachable PostgreSQL server before the first start.** The chart deploys none. Readiness and startup hit `/api/healthcheck`, which checks the database, so a missing server shows up as a pod that never turns Ready — and, because liveness does not check it, as a pod that never restarts either.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller.
|
||||
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. In **Gateway mode** neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace.
|
||||
- **A StorageClass** for `config.storageClass` (default `longhorn`), ideally one that supports volume expansion.
|
||||
|
||||
## Installation
|
||||
@@ -45,6 +45,22 @@ ingress:
|
||||
clusterIssuer: letsencrypt
|
||||
```
|
||||
|
||||
The same installation in **Gateway mode** instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
domain: share.example.com # still required: it is CORE_DEFAULT_DOMAIN
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
```
|
||||
|
||||
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; `ingress.clusterIssuer` is not used. `ingress.domain` is **still required** — it is also `CORE_DEFAULT_DOMAIN`, the host in every share link. And `config.maxUploadFileSize` stops being enforced, which for an upload service is the value to check first; see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
|
||||
|
||||
`helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
|
||||
|
||||
### What the 1Password items must contain
|
||||
@@ -84,7 +100,7 @@ kubectl -n <namespace> get secret <release>-zipline-db-secret -o jsonpath='{.dat
|
||||
|---|---|---|---|
|
||||
| `config.storage` | string | `50Gi` | Size of the uploads PVC. **Required** — it has a default, but `required()` rejects an explicit `null`. Growing it only works on a StorageClass that allows expansion; shrinking is refused by Kubernetes and fails the next upgrade on the PVC. |
|
||||
| `config.storageClass` | string | `longhorn` | StorageClass of the uploads PVC. **Required**, same mechanics as above; this chart pins a class rather than falling back to the cluster default. |
|
||||
| `config.maxUploadFileSize` | string | `30m` | Rendered into the ingress-nginx `proxy-body-size` / `client-max-body-size` annotations, so it takes effect **only while the Ingress is enabled**. See [Two upload ceilings](#two-upload-ceilings-and-the-one-that-actually-bites). |
|
||||
| `config.maxUploadFileSize` | string | `30m` | Rendered into the ingress-nginx `proxy-body-size` / `client-max-body-size` annotations, so it takes effect **only while the Ingress is enabled** — and is **not enforced in Gateway mode** either. See [Two upload ceilings](#two-upload-ceilings-and-the-one-that-actually-bites). |
|
||||
|
||||
### `zipline`
|
||||
|
||||
@@ -107,16 +123,52 @@ An erased block (`zipline.securityContext:` with no children, a `null` probe, a
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress and frees `clusterIssuer` — but **not** `domain`. |
|
||||
| `ingress.domain` | string | `null` | **Required, always.** Ingress host *and* `CORE_DEFAULT_DOMAIN`, the host Zipline puts into every share link it hands out. An empty value would hand out unusable links silently, so the StatefulSet carries its own `required()` ([style guide §12.1](Chart-Style-Guide#12-values-that-must-not-lie)). |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. |
|
||||
| `ingress.enabled` | bool | `true` | `false` omits the Ingress and frees `clusterIssuer` — but **not** `domain`. **Mutually exclusive with `httpRoute.enabled`.** |
|
||||
| `ingress.domain` | string | `null` | **Required, always.** Ingress host *and* `CORE_DEFAULT_DOMAIN`, the host Zipline puts into every share link it hands out. An empty value would hand out unusable links silently, so the StatefulSet carries its own `required()` ([style guide §12.1](Chart-Style-Guide#12-values-that-must-not-lie)). Also the `HTTPRoute`'s hostname while `httpRoute.hostnames` is empty. |
|
||||
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. Unused *and* unnecessary in Gateway mode. |
|
||||
| `ingress.className` | string | `nginx` | Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
|
||||
| `ingress.annotations` | object | `{}` | Merged with the cert-manager and body-size annotations the chart always sets. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
|
||||
|
||||
TLS is stored in `tls-<release>-zipline-ingress`.
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `httpRoute.enabled` | bool | `false` | Renders `<release>-zipline-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values. |
|
||||
| `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 and adds fields to it across releases. |
|
||||
| `httpRoute.hostnames` | list | `[]` | Empty means `[ingress.domain]`, so the hostname has a single source of truth. A hostname set here does **not** move `CORE_DEFAULT_DOMAIN` with it — every share link keeps naming `ingress.domain` until that value is changed too. |
|
||||
|
||||
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-zipline-service` on port **80** — the port **number**, because `backendRefs` cannot name a port the way the Ingress backend does. One path covers the UI, the API and the short URL of every upload.
|
||||
|
||||
## Operating notes
|
||||
|
||||
### Routing: Ingress or Gateway API
|
||||
|
||||
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `true`, `httpRoute.enabled` ships `false`.
|
||||
|
||||
| Rule | Why it is a hard failure and not a default |
|
||||
|---|---|
|
||||
| `ingress.enabled` and `httpRoute.enabled` cannot both be true — the render aborts naming **both** value paths | two data paths onto one Service under one hostname 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** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never in parallel. |
|
||||
| `httpRoute.parentRefs` must not be empty — the render aborts | 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. |
|
||||
| the mode is a **value**, never auto-detected | `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
|
||||
|
||||
**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, which live in `cluster-config`. `ingress.clusterIssuer` is unused **and** unnecessary in this mode.
|
||||
|
||||
**`ingress.domain` is required in this mode too, and not because of the route.** It is also `CORE_DEFAULT_DOMAIN`, with its own `required()` in the StatefulSet — the host Zipline writes into every share link it hands out, which is the whole point of the application. Set `httpRoute.hostnames` to a name `ingress.domain` does not carry and the route publishes one host while every link names another.
|
||||
|
||||
**Your upload ceiling becomes the Gateway's, and this is an upload service.** `config.maxUploadFileSize` exists only as an ingress-nginx annotation, and the Gateway API has **no** counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. In Ingress mode that value is the ceiling that actually bites, far below the app's pinned `UPLOADER_ADMIN_LIMIT=500mb`; in Gateway mode the ceiling is whatever the Gateway enforces, the rejection comes from the proxy rather than from Zipline, and nothing says a chart value was ignored. Set it at the Gateway or through its implementation's own policy object. The `/tmp` ephemeral-storage trap below is unchanged by the mode and is worth re-reading before raising anything.
|
||||
|
||||
**Whether the route attached at all is only visible in its status**, because no Kubernetes object reports a route that attaches to nothing:
|
||||
|
||||
```bash
|
||||
kubectl -n <namespace> get httproute <release>-zipline-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.
|
||||
|
||||
### Liveness does not check the database — on purpose
|
||||
|
||||
This is the positive example of [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie). `/api/healthcheck` checks the server *and* the database, so it belongs on readiness and startup; liveness uses `/`, so a database outage cannot restart the pod. Do not "harmonize" the three paths.
|
||||
@@ -141,7 +193,9 @@ Raise `zipline.startupProbe.failureThreshold` *before* a large jump, not after t
|
||||
|
||||
### Two upload ceilings, and the one that actually bites
|
||||
|
||||
The app-side limit is pinned by the chart at `UPLOADER_ADMIN_LIMIT=500mb` and is **not exposed as a value**. In front of it the Ingress caps the request body at `config.maxUploadFileSize` (`30m`), which is therefore the ceiling users meet: anything larger is rejected by ingress-nginx with a 413 before Zipline sees a byte, so the error page comes from the proxy and not from the app. Raise `config.maxUploadFileSize` to move it. With `ingress.enabled: false` the value is rendered nowhere — whatever proxy sits in front needs its own body-size limit.
|
||||
The app-side limit is pinned by the chart at `UPLOADER_ADMIN_LIMIT=500mb` and is **not exposed as a value**. In front of it the Ingress caps the request body at `config.maxUploadFileSize` (`30m`), which is therefore the ceiling users meet: anything larger is rejected by ingress-nginx with a 413 before Zipline sees a byte, so the error page comes from the proxy and not from the app. Raise `config.maxUploadFileSize` to move it. With both routing modes off the value is rendered nowhere — whatever proxy sits in front needs its own body-size limit.
|
||||
|
||||
**In Gateway mode it is likewise not enforced**, and that is a decision rather than a gap: the Gateway API has no body-size field, and rebuilding the limit would mean rendering an implementation-specific policy object. The ceiling is then the Gateway's, somewhere between "smaller than you expect" and the app's 500 MB, and the only way to know is to read the Gateway. See [Routing](#routing-ingress-or-gateway-api).
|
||||
|
||||
### Chunks go through `/tmp`, and `/tmp` is accounted for
|
||||
|
||||
@@ -174,6 +228,8 @@ A new `CORE_SECRET` logs every user out.
|
||||
|
||||
`ci/zipline/test-values.yaml` pins `storageClass: local-path`, `1Gi`, the host `zipline.ci.local` and two `op://` paths against the stub CRD. `ci/zipline/fixtures.yaml` supplies the two Kubernetes Secrets the 1Password operator would otherwise materialize, plus a throwaway Postgres 16 Deployment and Service. Because startup and readiness check the database, a green install-test here means the app really connected. The test runs on every PR that touches the chart — see [CI/CD](CI-CD).
|
||||
|
||||
**The install-test stays in Ingress mode; the route is held at render level.** The install runs exactly one configuration per chart and the two modes are exclusive, so one had to be picked — and here the Ingress mode is also the one that proves the database connection. [cyberchef](cyberchef) is the chart that pays the other price and installs in Gateway mode against the **real** Gateway-API CRDs, which is where a malformed `parentRefs` or `backendRefs` is caught. Instead `ci/httproute/zipline.yaml` is a **render-only overlay** that switches this chart into Gateway mode for `ci/run-httproute-match-test.sh`, which asserts that every `backendRefs` entry names a Service **and a port** the same render creates and that `parentRefs` is not empty. A chart that has an overlay and renders no route **fails** that test. Neither half proves that traffic flows: no Gateway controller runs in CI.
|
||||
|
||||
## See also
|
||||
|
||||
- [Chart style guide](Chart-Style-Guide) — §12.3, whose reference implementation this chart is
|
||||
|
||||
Reference in new issue
Block a user