docs: Gateway-API routing mode on the eight group-1 chart pages (#114)

bentopdf, garage, patchmon, rallly, searxng, sure, urlaubsverwaltung and
voucher-vault now offer the Gateway-API HTTPRoute as an alternative to the
Ingress (Gitea PR #116, group 1 of the #114 sweep). Per page: the summary
table's "Ingress" row becomes a "Routing" row naming both modes, an
installation example in Gateway mode, an httpRoute values table, a "Routing:
Ingress or Gateway API" section with the three hard rules and what each chart
in particular loses in that mode, and a CI note explaining why the install-test
stays in Ingress mode while the route is held by the httproute-match render
test through ci/httproute/<chart>.yaml.

Corrections rather than additions, where a page said something the second
routing template makes false:

  garage claimed nothing outside the Ingress template reads ingress.domain, so
  that switching the Ingress off freed domain as well as clusterIssuer. The
  HTTPRoute reads it as its hostname, so that now holds only while BOTH modes
  are off. bentopdf and urlaubsverwaltung carried the same claim in weaker
  form.

  sure's ingress.enabled row said it decides RAILS_ASSUME_SSL/RAILS_FORCE_SSL.
  Those now follow ingress.enabled OR httpRoute.enabled, because Gateway mode
  sets ingress.enabled false by construction while TLS is terminated at the
  Gateway - the old reading would have meant insecure cookies and http:// links
  with a green pod.

garage additionally gets the upload-limit regression spelled out: proxy-body-
size has no Gateway-API counterpart, so an S3 endpoint published through an
HTTPRoute inherits whatever limit the Gateway carries. #114 counted five charts
affected by this, measured on charts that hardcode the annotation; garage
depends on one without setting it and is a sixth.

Lessons-Learned gains "The switch that stopped meaning what it was read for" -
the sure finding in general form, with the grep that finds the remaining cases
(freesailarr and ytdlp-web-player both read .Values.ingress.enabled outside
their Ingress templates) - plus two lines in "The recurring shape".

No new Charts row and no _Sidebar entry: the charts already exist.
Chart-Style-Guide section 4 is unchanged - the convention was applied, not
altered.
jklein committed 2026-10-12 16:13:51 +00:00
1 parent d51fb49b93
commit e559776af6
9 files changed
+487 -29

No files matched your search

+27
@@ -501,6 +501,31 @@ The stub is right for `OnePasswordItem`. Nobody is testing the 1Password operato
And the part that stayed honest: **none of this proves that traffic flows.** No Gateway controller runs in k3d, so no route is ever reconciled and no status is ever written. What the pipeline claims is exactly what it checks — schema-valid object, `Ready` pod, a route pointing at a Service and a port the same render creates. And the part that stayed honest: **none of this proves that traffic flows.** No Gateway controller runs in k3d, so no route is ever reconciled and no status is ever written. What the pipeline claims is exactly what it checks — schema-valid object, `Ready` pod, a route pointing at a Service and a port the same render creates.
## The switch that stopped meaning what it was read for
Group 1 of the Gateway-API sweep (Gitea #114) touched eight charts that all looked alike: one Ingress object, no behaviour-bearing nginx annotation, nothing to decide. [sure](sure) was one of them, and it carried this:
```yaml
- name: RAILS_ASSUME_SSL
value: "{{ .Values.ingress.enabled }}"
- name: RAILS_FORCE_SSL
value: "{{ .Values.ingress.enabled }}"
```
Which was **correct**, and documented, and the right call when it was written: with the Ingress terminating TLS, Rails must trust the forwarded scheme; with nothing in front, both flags have to be off or a plain-HTTP port-forward cannot log in. One value, two readings — "is there an Ingress" and "is there a TLS-terminating proxy in front" — and for as long as an Ingress was the only way to publish, they were the same question.
The Gateway-API mode separates them, and it does so **by construction**: the two modes are mutually exclusive, so `httpRoute.enabled: true` *forces* `ingress.enabled: false` while TLS is still terminated in front of the app, now at the Gateway's listener. Rendered unchanged, a Gateway-mode release would have come up with both flags `false`. Rails would then not trust `X-Forwarded-Proto`, would hand out session cookies **without the `Secure` attribute over a connection that is in fact TLS**, would send no HSTS, and would build its absolute URLs — mail links, OIDC redirects — as `http://`.
Nothing about that fails. The pod becomes `Ready`, the UI works, the route is `Accepted`, every pipeline stage is green. The only way to notice is to read a `Set-Cookie` header.
**Now:** the question is asked once, in a helper (`sure.publishedOverTls`), as `ingress.enabled` **OR** `httpRoute.enabled`, and both flags read the answer. With `httpRoute.enabled` at its default of `false` that expression *is* `ingress.enabled` — which is why the change stayed **minor** and the default render is byte-identical; the behaviour differs only for a configuration that did not exist before.
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.
- **"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.
## The recurring shape ## The recurring shape
- **Green is a claim about the assertions that ran, not about the system.** - **Green is a claim about the assertions that ran, not about the system.**
@@ -540,3 +565,5 @@ And the part that stayed honest: **none of this proves that traffic flows.** No
- **A list merged by a key is a namespace, and two writers into one namespace need a referee at render time.** The API's own answers are an error naming a list index and a silent merge that drops one half; neither names a value anybody set. - **A list merged by a key is a namespace, and two writers into one namespace need a referee at render time.** The API's own answers are an error naming a list index and a silent merge that drops one half; neither names a value anybody set.
- **A fixture is defined by what it validates, not by what it stands in for.** The same trick — a schema-less stub CRD — is correct for one API and worthless for the next, and the question that separates them is whether the schema is the thing under test. Reusing a pattern because the shape matches is how a green run comes to mean "something was created". - **A fixture is defined by what it validates, not by what it stands in for.** The same trick — a schema-less stub CRD — is correct for one API and worthless for the next, and the question that separates them is whether the schema is the thing under test. Reusing a pattern because the shape matches is how a green run comes to mean "something was created".
- **When the fix needs a privilege, ask whether two cheap steps can replace one privileged one.** Splitting a pre-build into a seeding container plus a build container kept the hardening baseline intact across the whole pod, where the shorter single-container version would have bought a documented deviation *and* a suppression that silences the container that actually serves traffic. - **When the fix needs a privilege, ask whether two cheap steps can replace one privileged one.** Splitting a pre-build into a seeding container plus a build container kept the hardening baseline intact across the whole pod, where the shorter single-container version would have bought a documented deviation *and* a suppression that silences the container that actually serves traffic.
- **A values flag can be correct and still be the wrong question.** When one value answers two questions that happen to coincide, the day they stop coinciding is a day nothing fails: the flag still renders, the pod still starts, and the second question is now answered wrong. Adding a second mode to anything is the moment to ask which of its readers were reading it for the *other* reason.
- **A "nothing else reads this" note is a measurement, not a property.** It is true on the day it is written, it is what later decisions rest on, and the change that falsifies it is the change obliged to rewrite it — in every copy, the schema description included.
+58 -5
@@ -1,6 +1,6 @@
# bentopdf # bentopdf
Deploys [BentoPDF](https://github.com/alam00000/bentopdf), a browser-side PDF toolbox, as a stateless Deployment behind nginx. The pod serves static files and nothing else — every one of the roughly 130 tools runs in the visitor's browser. Deploys [BentoPDF](https://github.com/alam00000/bentopdf), a browser-side PDF toolbox, as a stateless Deployment behind nginx, published through an Ingress **or** a Gateway-API `HTTPRoute`. The pod serves static files and nothing else — every one of the roughly 130 tools runs in the visitor's browser.
| | | | | |
|---|---| |---|---|
@@ -8,7 +8,7 @@ Deploys [BentoPDF](https://github.com/alam00000/bentopdf), a browser-side PDF to
| Versions | not listed on purpose — read the current pair off `bentopdf/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `bentopdf/Chart.yaml`, see [Chart index](Charts) |
| Workloads | Deployment (1 replica) | | Workloads | Deployment (1 replica) |
| Storage | none — two emptyDirs for nginx, no PVC | | Storage | none — two emptyDirs for nginx, no PVC |
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required while enabled | | Routing | **two mutually exclusive modes.** Ingress (default): `ingress.domain` + `ingress.clusterIssuer` required while `ingress.enabled`. Gateway API: `httpRoute.enabled` with `httpRoute.parentRefs` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | none in the cluster; the browser fetches some WASM modules from the internet | | External dependencies | none in the cluster; the browser fetches some WASM modules from the internet |
| Secrets | none — no `OnePasswordItem` | | Secrets | none — no `OnePasswordItem` |
| Install-test | `ci/bentopdf/` (test values only, no fixtures) | | Install-test | `ci/bentopdf/` (test values only, no fixtures) |
@@ -18,7 +18,8 @@ The `-simple` image variant is deliberate: it is built from the same Dockerfile
## Prerequisites ## Prerequisites
- **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. Both are only needed while `ingress.enabled` is `true` — unlike most charts here, nothing outside the Ingress template reads them. - **cert-manager** with the `ClusterIssuer` named in `ingress.clusterIssuer`, and DNS for `ingress.domain` pointing at the ingress controller. Both are only needed while `ingress.enabled` is `true` — unlike most charts here, nothing outside the Ingress template reads them.
- **An ingress controller that passes the two cross-origin headers through.** Without them the Office conversions break; see [Two headers decide whether Office conversion works](#two-headers-decide-whether-office-conversion-works). - **In Gateway mode (`httpRoute.enabled: true`) instead:** the **Gateway-API CRDs** (standard channel — the chart cannot detect them, so without them the install fails with `no matches for kind HTTPRoute`), a **Gateway** whose listener admits routes from this namespace (`allowedRoutes`) and is named in `httpRoute.parentRefs`, and the **certificate on that Gateway's listener**. The chart renders none of the three; `ingress.clusterIssuer` is unused here.
- **A proxy that passes the two cross-origin headers through**, in either mode. Without them the Office conversions break; see [Two headers decide whether Office conversion works](#two-headers-decide-whether-office-conversion-works).
- No database, no cache, no 1Password item, no volume. This chart has no secrets at all. - No database, no cache, no 1Password item, no volume. This chart has no secrets at all.
## Installation ## Installation
@@ -42,6 +43,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. 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. `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.
## Configurable values ## Configurable values
@@ -75,16 +92,50 @@ Probes, security contexts and resources are null-safe: an erased block means "ch
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Switching it off frees `domain` and `clusterIssuer` — this chart has no second reader of either (upstream's `BASE_URL` counterpart is a build-time argument and empty in the published image). | | `ingress.enabled` | bool | `true` | Gates the whole Ingress template. **Mutually exclusive with `httpRoute.enabled`.** Switching it off frees `clusterIssuer` outright — but `domain` only while **both** routing modes are off, because the `HTTPRoute` reads it as its hostname. No *application* value reads either (upstream's `BASE_URL` counterpart is a build-time argument and empty in the published image). |
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host of the Ingress and of its TLS entry. | | `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host of the Ingress and of its TLS entry. |
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for a `X-Robots-Tag` header — see [A public instance gets indexed](#a-public-instance-gets-indexed). | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for a `X-Robots-Tag` header — see [A public instance gets indexed](#a-public-instance-gets-indexed). **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so everything set here is gone in that mode. |
TLS is stored in `tls-<release>-bentopdf-ingress`. TLS is stored in `tls-<release>-bentopdf-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-bentopdf-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-bentopdf-service` on port **80**.
## Operating notes ## 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.
**What this mode costs this chart, and it is not nothing.** `ingress.annotations` is BentoPDF's only lever for two things it cannot do itself, and in Gateway mode the chart has no channel for either: the `X-Robots-Tag` header that keeps a public instance out of search indexes, and the repair for a proxy that overrides the `.mjs` MIME type. The `NOTES.txt` of a Gateway-mode release says so, because "set it through `ingress.annotations`" is advice that silently stops applying. The two cross-origin headers are a different case — the image sends them itself, so they still arrive in both modes unless something strips them; that is a thing to **check** (the `curl` in `NOTES.txt`) rather than to configure.
**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>-bentopdf-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.
### A green pod only proves delivery ### A green pod only proves delivery
All three probes hit `/`, and what they prove is that nginx is handing out `index.html`. They say nothing about the tools: BentoPDF computes entirely in the visitor's browser, and not one of its tools touches this pod. A healthy release is a delivery receipt, not a statement about whether PDF processing works. All three probes hit `/`, and what they prove is that nginx is handing out `index.html`. They say nothing about the tools: BentoPDF computes entirely in the visitor's browser, and not one of its tools touches this pod. A healthy release is a delivery receipt, not a statement about whether PDF processing works.
@@ -155,6 +206,8 @@ Nothing to back up and nothing to migrate. No database, no volumes, no uploads
`ci/bentopdf/test-values.yaml` holds the two ingress values (`bentopdf.ci.local`, issuer `ci`) and — deliberately — a **non-empty** `bentopdf.config`. An empty config renders `{}`, which is byte-identical to the file the image already ships, so the install-test would pass without ever proving that the ConfigMap, the `subPath` mount into the web root and the `checksum/config` annotation work. There is no `fixtures.yaml`: nothing to stand up, no secrets (`cyberchef` is the precedent). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/bentopdf/test-values.yaml` holds the two ingress values (`bentopdf.ci.local`, issuer `ci`) and — deliberately — a **non-empty** `bentopdf.config`. An empty config renders `{}`, which is byte-identical to the file the image already ships, so the install-test would pass without ever proving that the ConfigMap, the `subPath` mount into the web root and the `checksum/config` annotation work. There is no `fixtures.yaml`: nothing to stand up, no secrets (`cyberchef` is the precedent). 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/bentopdf.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements - [Chart style guide](Chart-Style-Guide) — the rules this chart implements
+62 -4
@@ -10,7 +10,7 @@ That trade is deliberate, and the price is the first thing to understand about t
| Versions | not listed on purpose — read the current pair off `garage/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `garage/Chart.yaml`, see [Chart index](Charts) |
| Workloads | StatefulSet, 1 replica, **no init container** (the image has no shell — see [The image is `FROM scratch`](#the-image-is-from-scratch-and-that-shapes-the-whole-chart)) | | Workloads | StatefulSet, 1 replica, **no init container** (the image has no shell — see [The image is `FROM scratch`](#the-image-is-from-scratch-and-that-shapes-the-whole-chart)) |
| Storage | **two** RWO PVCs: one for metadata **and** data, one for the metadata snapshots | | Storage | **two** RWO PVCs: one for metadata **and** data, one for the metadata snapshots |
| Ingress | **off by default**, built to the convention so it can be switched on | | Routing | **both modes off by default**, built to the convention so either can be switched on: Ingress (`ingress.enabled`) or Gateway API (`httpRoute.enabled`), mutually exclusive — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | none — no database, no cache, no other service | | External dependencies | none — no database, no cache, no other service |
| Secrets | 2 × `OnePasswordItem`, four fields in total | | Secrets | 2 × `OnePasswordItem`, four fields in total |
| Network | ClusterIP Service on 3900 (S3) and 3903 (admin). No RPC port in the Service, and no NetworkPolicy yet. The admin port serves two things unauthenticated — `/health` (token-free by design, the probes need it) and `/metrics` (`metrics_require_token` defaults to **false**) — and answers everything else with 401/403 until an admin token exists, from the config *or* from `garage admin-token create` | | Network | ClusterIP Service on 3900 (S3) and 3903 (admin). No RPC port in the Service, and no NetworkPolicy yet. The admin port serves two things unauthenticated — `/health` (token-free by design, the probes need it) and `/metrics` (`metrics_require_token` defaults to **false**) — and answers everything else with 401/403 until an admin token exists, from the config *or* from `garage admin-token create` |
@@ -56,6 +56,22 @@ storage:
storageClass: longhorn-encrypted-ns storageClass: longhorn-encrypted-ns
``` ```
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: <the public hostname> # 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. Both modes are off by default here, so this is an opt-in either way. See [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api).
`helm install` prints the chart's `NOTES.txt`, which names the four vault fields, the verification sequence for this release, and the traps in short form. Read it — it is the per-release version of this page. `helm install` prints the chart's `NOTES.txt`, which names the four vault fields, the verification sequence for this release, and the traps in short form. Read it — it is the per-release version of this page.
### What the 1Password items must contain ### What the 1Password items must contain
@@ -216,14 +232,52 @@ Neither block is null-safe, on purpose: an erased size yields an invalid PVC tha
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `false` | Off, because the consumer speaks S3 inside the cluster. Switching it on publishes the **S3 API** — there is no web UI to expose. | | `ingress.enabled` | bool | `false` | Off, because the consumer speaks S3 inside the cluster. Switching it on publishes the **S3 API** — there is no web UI to expose. **Mutually exclusive with `httpRoute.enabled`.** |
| `ingress.clusterIssuer` | string | `null` | Required while enabled. | | `ingress.clusterIssuer` | string | `null` | Required while enabled. |
| `ingress.domain` | string | `null` | Required while enabled. Checked in both directions ([§12.1](Chart-Style-Guide#12-values-that-must-not-lie)): nothing outside the Ingress template reads it, so switching the Ingress off frees **both** values. | | `ingress.domain` | string | `null` | Required while **either** routing mode is on. Checked in both directions ([§12.1](Chart-Style-Guide#12-values-that-must-not-lie)), and the answer **moved with #114**: the `HTTPRoute` reads it as its hostname when `httpRoute.hostnames` is empty, so `domain` is free only while both modes are off. `clusterIssuer` is unchanged — read by the Ingress alone. The vhost suffix stays its own value (`garage.s3.rootDomain`). |
| `ingress.className` | string | `nginx` | | | `ingress.className` | string | `nginx` | |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. An S3 endpoint behind nginx usually wants `nginx.ingress.kubernetes.io/proxy-body-size: "0"` — without it the controller's 1m default truncates uploads. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. An S3 endpoint behind nginx usually wants `nginx.ingress.kubernetes.io/proxy-body-size: "0"` — without it the controller's 1m default truncates uploads. **That one has no counterpart in Gateway mode** and it matters here more than anywhere: see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). |
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-garage-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-garage-service` on port **`garage.ports.s3` (default 3900)**.
**This is the one chart in the repo whose `backendRefs` port is rendered from a *value*.** `garage.ingress.yaml` references the Service port **by name** (`s3`) and is therefore immune to the number changing; `backendRefs` takes a `PortNumber` and cannot name a port, so a literal `3900` in the route would become a lie the moment `garage.ports.s3` is changed. The route renders the number from the same `garage.settings` helper the Service renders its port from, so the two cannot drift. The **admin** port is deliberately not routed in either mode: with this chart's configuration it serves `/health` and `/metrics` unauthenticated and answers everything else 401/403, which is a monitoring surface for the cluster, not something to publish.
## Traps ## Traps
### Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. `ingress.enabled` ships `false`, `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 AN OBJECT STORE.** In Ingress mode the advice under `ingress.annotations` is `proxy-body-size: "0"`, because ingress-nginx's 1 MB default truncates uploads. The Gateway API has **no** counterpart — it knows no body size at all; an upload limit is implementation-specific policy — and this chart renders no policy object, which would bind it to one implementation. So whatever limit the Gateway carries is the limit S3 `PUT`s get, and the symptom is uploads failing **by size** rather than by permission. Raise it at the Gateway or through that implementation's own policy object, next to the Gateway and not here. The Gateway-mode `NOTES.txt` prints this.
Worth noting against the issue that planned this sweep: #114 listed five charts as losing an upload limit in Gateway mode, measured on charts that *hardcode* the annotation. `garage` sets `annotations: {}` and so was not among them, but it **depends** on the limit all the same — it is a sixth case, and the distinction ("sets no behaviour-bearing annotation" is not "needs none") is now written down in the README.
**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>-garage-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 metadata is not in your volume backup ### The metadata is not in your volume backup
In any usable form, that is. **A file-system snapshot of a live metadata database is not a restore point**: taken during a metadata write it is corrupt, and Garage says so itself — which is why it recommends *its own* snapshot mechanism in all cases, not as an extra. So the Longhorn snapshot and the OVH backup cover the **blocks**; the index they point into is covered by `metadata_auto_snapshot_interval` and by nothing else. In any usable form, that is. **A file-system snapshot of a live metadata database is not a restore point**: taken during a metadata write it is corrupt, and Garage says so itself — which is why it recommends *its own* snapshot mechanism in all cases, not as an extra. So the Longhorn snapshot and the OVH backup cover the **blocks**; the index they point into is covered by `metadata_auto_snapshot_interval` and by nothing else.
@@ -357,6 +411,10 @@ Three limits of that coverage, named so they are not mistaken for tested:
- **Longhorn is not involved.** The snapshot semantics this chart's whole storage layout rests on are a property of the storage layer; the runner has `local-path`. - **Longhorn is not involved.** The snapshot semantics this chart's whole storage layout rests on are a property of the storage layer; the runner has `local-path`.
- **Durability is not testable.** Whether an `fsync` survives a power cut is not observable in a container. - **Durability is not testable.** Whether an `fsync` survives a power cut is not observable in a container.
**The install-test keeps the Ingress on, and the route is held at render level — for this chart that is not a toss-up.** `ci/garage/test-values.yaml` sets `ingress.enabled: true` deliberately, so the install proves the Service port the Ingress names **by name** (`s3`) exists; a typo there is otherwise invisible until somebody opens the URL. An `HTTPRoute` cannot make that reference at all — `backendRefs` takes a `PortNumber` — so switching the fixture into Gateway mode would have traded a check that catches a renamed port for one that does not, *and* given up garage's only Ingress coverage, for a CRD schema [cyberchef](cyberchef)'s fixture already exercises against the **real** Gateway-API CRDs.
Instead `ci/httproute/garage.yaml` is a **render-only overlay** that switches this chart into Gateway mode for `ci/run-httproute-match-test.sh`. That test asserts every `backendRefs` entry names a Service **and a port** the same render creates — which is exactly the drift this chart risks, since its route renders the port from `garage.ports.s3` rather than from a literal. A chart that has an overlay and renders no route **fails** the test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI. See [CI/CD](CI-CD).
## See also ## See also
- [stalwart](stalwart) — the first consumer; its blob store points at this release, and the two share the S3 vault item - [stalwart](stalwart) — the first consumer; its blob store points at this release, and the two share the S3 vault item
+55 -3
@@ -8,7 +8,7 @@ Deploys [PatchMon](https://patchmon.net/), a patch-status monitor for Linux and
| Versions | not listed on purpose — read the current pair off `patchmon/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `patchmon/Chart.yaml`, see [Chart index](Charts) |
| Workloads | one Deployment (1 replica), optional `guacd` sidecar in the same pod | | Workloads | one Deployment (1 replica), optional `guacd` sidecar in the same pod |
| Storage | none — the image is stateless; `/tmp` is an emptyDir | | Storage | none — the image is stateless; `/tmp` is an emptyDir |
| Ingress | yes, gated by `ingress.enabled`; `ingress.domain` survives the gate | | Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`); `ingress.domain` survives both gates — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | Postgres, Redis, an OIDC provider | | External dependencies | Postgres, Redis, an OIDC provider |
| Secrets | 4 × `OnePasswordItem` | | Secrets | 4 × `OnePasswordItem` |
| Install-test | `ci/patchmon/` (fixtures with postgres, redis and a dex OIDC provider) | | Install-test | `ci/patchmon/` (fixtures with postgres, redis and a dex OIDC provider) |
@@ -48,6 +48,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. `ingress.domain` is still required — it builds `CORS_ORIGIN` and the probes' `Host` header. 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. `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 ### What the 1Password items must contain
@@ -119,16 +135,50 @@ kubectl -n <namespace> get onepassworditem
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | `false` omits the Ingress object and frees `clusterIssuer` — **but not `domain`**. | | `ingress.enabled` | bool | `true` | `false` omits the Ingress object and frees `clusterIssuer` — **but not `domain`**. **Mutually exclusive with `httpRoute.enabled`.** |
| `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host, the server's `CORS_ORIGIN` (`https://<domain>`), and the `Host` header of all three probes. | | `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host, the server's `CORS_ORIGIN` (`https://<domain>`), and the `Host` header of all three probes. |
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode** — but nothing this chart needs lives here: the server's WebSocket upgrade works on a plain route and `TRUST_PROXY` is an environment variable. |
The Ingress carries a single `/` prefix path: the 2.x server serves the frontend and `/api/*` from one process, WebSockets included. TLS is stored in `tls-<release>-patchmon-ingress`. The Ingress carries a single `/` prefix path: the 2.x server serves the frontend and `/api/*` from one process, WebSockets included. TLS is stored in `tls-<release>-patchmon-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-patchmon-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-patchmon-service` on port **80**. One path covers everything, for the same reason the Ingress needs only one.
## Operating notes ## 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 builds `CORS_ORIGIN` (with its own `required()`) and the explicit `Host` header all three probes send. The consequence is the one this page already describes for the Ingress and it is unchanged by a Gateway in front: the 2.x server answers 403 to any request whose `Host` is neither loopback nor a `CORS_ORIGIN` host, so a wrong value keeps the pod out of `Ready` forever. If you set `httpRoute.hostnames` to a name `ingress.domain` does not carry, the server rejects that name — set both together. `TRUST_PROXY` stays right either way; a Gateway is a reverse proxy too.
**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>-patchmon-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.
### A wrong `ingress.domain` keeps the pod out of Ready forever ### A wrong `ingress.domain` keeps the pod out of Ready forever
The 2.x server answers **403** to any request whose `Host` header is neither loopback nor a `CORS_ORIGIN` host, and `CORS_ORIGIN` is built from `ingress.domain`. Kubelet probes address the pod IP, so they must send the public hostname explicitly — which is why all three probe defaults carry an `httpHeaders` entry rendered through `tpl` from `ingress.domain`. The 2.x server answers **403** to any request whose `Host` header is neither loopback nor a `CORS_ORIGIN` host, and `CORS_ORIGIN` is built from `ingress.domain`. Kubelet probes address the pod IP, so they must send the public hostname explicitly — which is why all three probe defaults carry an `httpHeaders` entry rendered through `tpl` from `ingress.domain`.
@@ -193,6 +243,8 @@ With `patchmon.guacd.enabled: true` the daemon runs beside the server and is rea
`ci/patchmon/` holds `test-values.yaml` (op:// refs against the stub CRD, `patchmon.ci.local`) and `fixtures.yaml` (throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the four dummy secrets). Because the 2.x server is a single stateless container, no PV, StorageClass or service-alias fixtures are needed and the chart takes the default install timeout. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/patchmon/` holds `test-values.yaml` (op:// refs against the stub CRD, `patchmon.ci.local`) and `fixtures.yaml` (throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the four dummy secrets). Because the 2.x server is a single stateless container, no PV, StorageClass or service-alias fixtures are needed and the chart takes the default install timeout. 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/patchmon.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — §12.5 is written from this chart's probes - [Chart style guide](Chart-Style-Guide) — §12.5 is written from this chart's probes
+55 -3
@@ -8,7 +8,7 @@ Deploys [Rallly](https://rallly.co/), a self-hosted scheduling and poll app, as
| Versions | not listed on purpose — read the current pair off `rallly/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `rallly/Chart.yaml`, see [Chart index](Charts) |
| Workloads | Deployment (1 replica) | | Workloads | Deployment (1 replica) |
| Storage | none persistent — an emptyDir mounted over the Next.js cache directory | | Storage | none persistent — an emptyDir mounted over the Next.js cache directory |
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required | | Routing | **two mutually exclusive modes.** Ingress (default): `ingress.domain` + `ingress.clusterIssuer`. Gateway API: `httpRoute.enabled` with `httpRoute.parentRefs` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | Postgres, an OIDC provider, optionally an SMTP server | | External dependencies | Postgres, an OIDC provider, optionally an SMTP server |
| Secrets | 3 × `OnePasswordItem`, plus a 4th when SMTP is enabled | | Secrets | 3 × `OnePasswordItem`, plus a 4th when SMTP is enabled |
| Install-test | `ci/rallly/` (fixtures + test values) | | Install-test | `ci/rallly/` (fixtures + test values) |
@@ -50,6 +50,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. `ingress.domain` is still required — it is also `NEXT_PUBLIC_BASE_URL` and the base of the OIDC redirect URI. 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. `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 ### What the 1Password items must contain
@@ -115,16 +131,50 @@ Probe, `securityContext` and `resources` blocks may be erased (`null`) — the c
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Switches the Ingress object off. **`ingress.domain` stays required** — it is also the app's `NEXT_PUBLIC_BASE_URL`. | | `ingress.enabled` | bool | `true` | Switches the Ingress object off. **`ingress.domain` stays required** — it is also the app's `NEXT_PUBLIC_BASE_URL`. **Mutually exclusive with `httpRoute.enabled`.** |
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
| `ingress.domain` | string | `null` | **Required.** Ingress host, public base URL and the base of the OIDC redirect URI in one value. | | `ingress.domain` | string | `null` | **Required.** Ingress host, public base URL and the base of the OIDC redirect URI in one value. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, and nothing this chart needs lives here by default. |
TLS is stored in `tls-<release>-rallly-ingress`. TLS is stored in `tls-<release>-rallly-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-rallly-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-rallly-service` on port **80**. The Next.js server serves the app, its API routes and `/api/auth/*` from one process.
## Operating notes ## 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.
**The hostname is not a routing detail here, in either mode.** `NEXT_PUBLIC_BASE_URL` follows `ingress.domain` and *not* `httpRoute.hostnames`, and the OIDC redirect URI registered at the provider is built from it. So a Gateway-mode release published under a name `ingress.domain` does not carry gives you an instance whose every link and whose sign-in leave for the other host — and with `httpRoute.hostnames` set for a migration there are **three** sides to keep in agreement: the provider, the route, and `ingress.domain`.
**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>-rallly-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.
### Sign-in goes through the provider, and only through the provider ### Sign-in goes through the provider, and only through the provider
The chart sets `EMAIL_LOGIN_ENABLED` and `REGISTRATION_ENABLED` to `"false"` in the template, not through a value: there is no magic-link login and no self-registration to fall back on. Every account arrives through the OIDC provider. A wrong OIDC item therefore does not degrade the login, it removes it — including your own way back in. The chart sets `EMAIL_LOGIN_ENABLED` and `REGISTRATION_ENABLED` to `"false"` in the template, not through a value: there is no magic-link login and no self-registration to fall back on. Every account arrives through the OIDC provider. A wrong OIDC item therefore does not degrade the login, it removes it — including your own way back in.
@@ -178,6 +228,8 @@ The container context drops all capabilities and adds none — and the `add:` ke
`ci/rallly/` holds `test-values.yaml` (`op://` refs against the stub CRD, `rallly.ci.local`, `supportEmail`, SMTP left off) and `fixtures.yaml` (a throwaway Postgres, a **dex** OIDC provider so the discovery URL actually answers, and the three Secrets the 1Password operator would otherwise materialize). The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/rallly/` holds `test-values.yaml` (`op://` refs against the stub CRD, `rallly.ci.local`, `supportEmail`, SMTP left off) and `fixtures.yaml` (a throwaway Postgres, a **dex** OIDC provider so the discovery URL actually answers, and the three Secrets the 1Password operator would otherwise materialize). 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/rallly.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements - [Chart style guide](Chart-Style-Guide) — the rules this chart implements
+57 -3
@@ -8,7 +8,7 @@ Deploys [SearXNG](https://docs.searxng.org/), a metasearch engine, as a stateles
| Versions | not listed on purpose — read the current pair off `searxng/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `searxng/Chart.yaml`, see [Chart index](Charts) |
| Workloads | Deployment (1 replica) | | Workloads | Deployment (1 replica) |
| Storage | none — two emptyDirs (`/tmp`, `/var/cache/searxng`) | | Storage | none — two emptyDirs (`/tmp`, `/var/cache/searxng`) |
| Ingress | yes, `ingress.domain` **always** required, `ingress.clusterIssuer` only while the Ingress is on | | Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`). `ingress.domain` **always** required, `ingress.clusterIssuer` only while the Ingress is on — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | none by default; an external Valkey/Redis only for the limiter | | External dependencies | none by default; an external Valkey/Redis only for the limiter |
| Secrets | 1 × `OnePasswordItem`, a second one when a Valkey is configured | | Secrets | 1 × `OnePasswordItem`, a second one when a Valkey is configured |
| Install-test | `ci/searxng/` (fixtures + test values) | | Install-test | `ci/searxng/` (fixtures + test values) |
@@ -44,6 +44,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. `ingress.domain` is still required — it is also `SEARXNG_BASE_URL`. 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 whether this release is rate limited at all. Read it — it is the per-release version of this page. `helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — including whether this release is rate limited at all. Read it — it is the per-release version of this page.
### What the 1Password items must contain ### What the 1Password items must contain
@@ -110,16 +126,52 @@ The container port (8080, named `http`) is **not** a value: a value could lie, b
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Gates the Ingress object **only**. `ingress.domain` stays required — see [`ingress.domain` is read twice](#ingressdomain-is-read-twice). | | `ingress.enabled` | bool | `true` | Gates the Ingress object **only**. `ingress.domain` stays required — see [`ingress.domain` is read twice](#ingressdomain-is-read-twice). **Mutually exclusive with `httpRoute.enabled`.** |
| `ingress.domain` | string | `null` | **Required, with or without the Ingress.** Ingress host, and `SEARXNG_BASE_URL` (`server.base_url`). | | `ingress.domain` | string | `null` | **Required, with or without the Ingress.** Ingress host, and `SEARXNG_BASE_URL` (`server.base_url`). |
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. Freed by `ingress.enabled: false`. | | `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. Freed by `ingress.enabled: false`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for forward-auth. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. The place for forward-auth. **No counterpart in Gateway mode** — and for this chart that is the sharpest consequence of the switch, see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). |
TLS is stored in `tls-<release>-searxng-ingress`. TLS is stored in `tls-<release>-searxng-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-searxng-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-searxng-service` on port **80**. SearXNG serves its UI, `/search` and `/healthz` from one worker.
## Operating notes ## 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.
**In Gateway mode the forward-auth escape hatch does not exist in the chart**, and that is the sharpest consequence of the switch here. SearXNG has no login of its own; `ingress.annotations` is where this page documents putting one in front of it (Authentik and friends), and the Gateway API has no annotation channel at all. The chart renders no implementation-specific policy object on purpose, so "who may use this instance" becomes entirely the Gateway's question: a listener that is not publicly reachable, or that implementation's own auth filter, configured next to the Gateway. The Gateway-mode `NOTES.txt` says so, because "put auth in front of it via `ingress.annotations`" is advice that silently stops applying.
Two things are **not** affected. The rate limiter runs inside SearXNG against a Valkey (`settings.limiter`), not at the proxy. And `SEARXNG_BASE_URL` still follows `ingress.domain`, not `httpRoute.hostnames` — signed image-proxy URLs and the OpenSearch description keep naming that host whatever the route publishes.
**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>-searxng-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.
### `ingress.domain` is read twice ### `ingress.domain` is read twice
`ingress.enabled: false` removes the Ingress and frees `clusterIssuer` — and nothing else. The domain is read a second time, outside the Ingress template, to build `SEARXNG_BASE_URL`, and carries its own `required()` there. That read cannot be dropped: `server.base_url` is what SearXNG puts into the OpenSearch description, into the image-proxy URLs it signs and into every absolute link. Left empty it renders `https:///`, the pod goes green, and browsers are sent to a broken address. `ingress.enabled: false` removes the Ingress and frees `clusterIssuer` — and nothing else. The domain is read a second time, outside the Ingress template, to build `SEARXNG_BASE_URL`, and carries its own `required()` there. That read cannot be dropped: `server.base_url` is what SearXNG puts into the OpenSearch description, into the image-proxy URLs it signs and into every absolute link. Left empty it renders `https:///`, the pod goes green, and browsers are sent to a broken address.
@@ -218,6 +270,8 @@ The chart's `customManagers` block overrides `versioning` with an anchored regex
The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/searxng.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements - [Chart style guide](Chart-Style-Guide) — the rules this chart implements
+61 -3
@@ -8,7 +8,7 @@ Deploys [Sure](https://github.com/we-promise/sure), the maintained community for
| Versions | not listed on purpose — read the current pair off `sure/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `sure/Chart.yaml`, see [Chart index](Charts) |
| Workloads | StatefulSet (1 replica, 2 containers: `web` + `worker`, plus one init container) | | Workloads | StatefulSet (1 replica, 2 containers: `web` + `worker`, plus one init container) |
| Storage | one RWO PVC at `/rails/storage`, mounted into **both** containers | | Storage | one RWO PVC at `/rails/storage`, mounted into **both** containers |
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required | | Routing | **two mutually exclusive modes.** Ingress (default): `ingress.domain` + `ingress.clusterIssuer`. Gateway API: `httpRoute.enabled` with `httpRoute.parentRefs` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | Postgres, Redis | | External dependencies | Postgres, Redis |
| Secrets | 3 × `OnePasswordItem` | | Secrets | 3 × `OnePasswordItem` |
| Install-test | `ci/sure/` (fixtures + test values, `INSTALL_TIMEOUT=900`) | | Install-test | `ci/sure/` (fixtures + test values, `INSTALL_TIMEOUT=900`) |
@@ -46,6 +46,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. `ingress.domain` is still required (`APP_DOMAIN`, `WEBAUTHN_RP_ID`), and the Gateway's listener **must** terminate TLS: the chart renders `RAILS_FORCE_SSL=true` in this mode. 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. `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 ### What the 1Password items must contain
@@ -118,16 +134,56 @@ The chart renders `OnePasswordItem` CRs; the operator turns each into a Kubernet
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Switches the Ingress object off — and with it `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL`, which the chart renders from this flag. **`ingress.domain` stays required**: see [`ingress.domain` is read four times](#ingressdomain-is-read-four-times). | | `ingress.enabled` | bool | `true` | Switches the Ingress object off. **Mutually exclusive with `httpRoute.enabled`.** **`ingress.domain` stays required**: see [`ingress.domain` is read four times](#ingressdomain-is-read-four-times). Since #114 this flag no longer decides the TLS flags on its own: `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL` render from `ingress.enabled` **OR** `httpRoute.enabled` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api). |
| `ingress.domain` | string | `null` | **Required.** Ingress host, `APP_DOMAIN` (mail links), `WEBAUTHN_RP_ID` and `WEBAUTHN_ALLOWED_ORIGINS`. | | `ingress.domain` | string | `null` | **Required.** Ingress host, `APP_DOMAIN` (mail links), `WEBAUTHN_RP_ID` and `WEBAUTHN_ALLOWED_ORIGINS`. |
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, and nothing this chart needs lives here — the TLS flags are environment variables and are handled in both modes. |
TLS is stored in `tls-<release>-sure-ingress`. TLS is stored in `tls-<release>-sure-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-sure-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-sure-service` on port **80**. The web container serves everything; the Sidekiq worker has no HTTP port and is never routed to.
## Operating notes ## 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.
**HOW RAILS IS TOLD ABOUT TLS NO LONGER HANGS ON `ingress.enabled` ALONE, AND THAT WAS A DEFECT WAITING TO HAPPEN.** `RAILS_ASSUME_SSL` and `RAILS_FORCE_SSL` used to render from `ingress.enabled` — exactly right while an Ingress was the only way to publish. Gateway mode sets `ingress.enabled: false` **by construction**, while TLS is still terminated in front of the app, now at the Gateway's listener. Left as it was, a Gateway-mode release would have come up with both flags `false`: Rails would not trust the forwarded scheme, would hand out session cookies **without the `Secure` attribute over a TLS connection**, would send no HSTS, and would build absolute URLs — mail links, OIDC redirects — as `http://`. The pod is `Ready`, the UI works, and the only way to notice is to read a `Set-Cookie` header.
Since #114 the question is asked once, in the `sure.publishedOverTls` helper, as `ingress.enabled` **OR** `httpRoute.enabled`. With `httpRoute.enabled` at its default of `false` that expression *is* `ingress.enabled`, so nothing changed for an existing release. Measured in all three states: Ingress `true/true`, Gateway `true/true`, both off `false/false` — the last is what lets a plain-HTTP port-forward log in at all.
The corollary for the operator: **a Gateway listener that is plain HTTP will hand out cookies the browser never returns**, because the chart now assumes the Gateway terminates TLS. That is the one thing to get right before switching this chart over.
**`WEBAUTHN_RP_ID` is the other sharp edge, and the route does not move it.** `APP_DOMAIN`, `WEBAUTHN_RP_ID` and `WEBAUTHN_ALLOWED_ORIGINS` all follow `ingress.domain`, not `httpRoute.hostnames`. Publishing under a name `ingress.domain` does not carry registers passkeys against the wrong relying party — and changing `ingress.domain` afterwards to fix it **invalidates every credential already registered** (upstream `docs/hosting/webauthn.md`).
**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>-sure-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.
### What each probe actually proves ### What each probe actually proves
Rails' `/up` route (`get "up" => "rails/health#show"`) returns 200 as soon as the app has booted without raising. It touches **no** database and **no** Redis. The widespread assumption that it tests the database is exactly what [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie) exists to catch — upstream's own Helm chart, which ships inside the application repository, uses `/up` for readiness, and this chart deliberately does not copy that. Rails' `/up` route (`get "up" => "rails/health#show"`) returns 200 as soon as the app has booted without raising. It touches **no** database and **no** Redis. The widespread assumption that it tests the database is exactly what [style guide §12.3](Chart-Style-Guide#12-values-that-must-not-lie) exists to catch — upstream's own Helm chart, which ships inside the application repository, uses `/up` for readiness, and this chart deliberately does not copy that.
@@ -226,6 +282,8 @@ The registry carries releases (`0.7.4`), prereleases (`-alpha.N`, `-rc.N`, `-hot
`ci/sure/` holds `test-values.yaml` (op:// refs against the stub CRD, `sure.ci.local`), `fixtures.yaml` (throwaway Postgres 16 — the version upstream's compose file uses — plus Redis and the dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=900`, because the rollout wait has to outlast a first start that creates the whole schema. Measured on a green run: the pod was Ready about 80 seconds after `helm install`, so the budget is deliberately generous rather than tight. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/sure/` holds `test-values.yaml` (op:// refs against the stub CRD, `sure.ci.local`), `fixtures.yaml` (throwaway Postgres 16 — the version upstream's compose file uses — plus Redis and the dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=900`, because the rollout wait has to outlast a first start that creates the whole schema. Measured on a green run: the pod was Ready about 80 seconds after `helm install`, so the budget is deliberately generous rather than tight. 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/sure.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements - [Chart style guide](Chart-Style-Guide) — the rules this chart implements
+57 -5
@@ -8,7 +8,7 @@ Deploys [Urlaubsverwaltung](https://urlaubsverwaltung.cloud/), a leave and absen
| Versions | not listed on purpose — read the current pair off `urlaubsverwaltung/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `urlaubsverwaltung/Chart.yaml`, see [Chart index](Charts) |
| Workloads | Deployment (1 replica) | | Workloads | Deployment (1 replica) |
| Storage | none — `/tmp` is an emptyDir, all state is in the external database | | Storage | none — `/tmp` is an emptyDir, all state is in the external database |
| Ingress | yes, `ingress.domain` + `ingress.clusterIssuer` required while enabled | | Routing | **two mutually exclusive modes.** Ingress (default): `ingress.domain` + `ingress.clusterIssuer` required while `ingress.enabled`. Gateway API: `httpRoute.enabled` with `httpRoute.parentRefs` — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | a database (JDBC), an OIDC provider | | External dependencies | a database (JDBC), an OIDC provider |
| Secrets | 2 × `OnePasswordItem` | | Secrets | 2 × `OnePasswordItem` |
| Install-test | `ci/urlaubsverwaltung/` (fixtures + test values, `INSTALL_TIMEOUT=600`) | | Install-test | `ci/urlaubsverwaltung/` (fixtures + test values, `INSTALL_TIMEOUT=600`) |
@@ -46,6 +46,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. 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. Read it **at install time** in particular, because two of the overrides it describes only work then (see [Environment variables](#urlaubsverwaltungenv-is-additive-only-and-a-collision-is-lost-on-upgrade)). `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. Read it **at install time** in particular, because two of the overrides it describes only work then (see [Environment variables](#urlaubsverwaltungenv-is-additive-only-and-a-collision-is-lost-on-upgrade)).
### What the 1Password items must contain ### What the 1Password items must contain
@@ -100,16 +116,50 @@ The whole `resources` and `securityContext` blocks, and each probe, may also be
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Gates the whole Ingress template. Unlike some other charts here, **`ingress.domain` is read nowhere else** — no environment variable of the container derives from it, so switching the Ingress off makes both `domain` and `clusterIssuer` unnecessary. | | `ingress.enabled` | bool | `true` | Gates the whole Ingress template. **Mutually exclusive with `httpRoute.enabled`.** Unlike some other charts here, **`ingress.domain` is read by no environment variable of the container** — `UV_MAIL_APPLICATIONURL` is a hardcoded placeholder and deliberately not derived from it — so `domain` is unnecessary while **both** routing modes are off, and `clusterIssuer` whenever the Ingress is off. |
| `ingress.domain` | string | `null` | **Required while `ingress.enabled` is true.** Host and TLS subject. The address the application considers its own is the OIDC `redirect-uri` out of the 1Password item, not this value — the two can drift apart without any complaint. | | `ingress.domain` | string | `null` | **Required while either routing mode is on.** Host and TLS subject; in Gateway mode the `HTTPRoute`'s single hostname. The address the application considers its own is the OIDC `redirect-uri` out of the 1Password item, not this value — the two can drift apart without any complaint. |
| `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while `ingress.enabled` is true.** cert-manager `ClusterIssuer`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, and nothing this chart needs lives here. |
TLS is stored in `tls-<release>-urlaubsverwaltung-ingress`. The Ingress publishes the app at `/` with no path restriction, so `/swagger-ui.html` and `/v3/api-docs` — enabled by default in this app version and not turned off by the chart — are served under the same host. Both sit behind the login (302 to the provider like every other path), so this is exposure of an API description, not of data; `springdoc.api-docs.enabled` / `springdoc.swagger-ui.enabled` can be added through `urlaubsverwaltung.env`. TLS is stored in `tls-<release>-urlaubsverwaltung-ingress`. The Ingress publishes the app at `/` with no path restriction, so `/swagger-ui.html` and `/v3/api-docs` — enabled by default in this app version and not turned off by the chart — are served under the same host. Both sit behind the login (302 to the provider like every other path), so this is exposure of an API description, not of data; `springdoc.api-docs.enabled` / `springdoc.swagger-ui.enabled` can be added through `urlaubsverwaltung.env`. The `HTTPRoute` publishes the app the same way — one `PathPrefix: /` rule — so that exposure is identical in Gateway mode.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-urlaubsverwaltung-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-urlaubsverwaltung-service` on port **80**. The Spring Boot application serves the UI, `/login` and `/actuator` from one port.
## Operating notes ## 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.
**The hostname and the application's idea of itself still do not agree by themselves**, and this mode does not change that. No environment variable of the container derives from `ingress.domain` or from `httpRoute.hostnames`; what the application considers its own address is the OIDC `redirect-uri` in the 1Password item. So publishing under a new name means updating both, or the route answers and the login bounces with a `redirect-uri` mismatch while the pod stays green.
**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>-urlaubsverwaltung-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 three health paths and what each one answers for ### The three health paths and what each one answers for
The chart fixes two health settings in the deployment template rather than exposing them as values, because `urlaubsverwaltung.env` cannot reliably override them anyway (see below): The chart fixes two health settings in the deployment template rather than exposing them as values, because `urlaubsverwaltung.env` cannot reliably override them anyway (see below):
@@ -211,6 +261,8 @@ kubectl -n <namespace> rollout restart deployment/<release>-urlaubsverwaltung-de
`ci/urlaubsverwaltung/` holds `test-values.yaml` (`op://` refs against the stub CRD, host `uv.ci.local`, `clusterIssuer: ci`), `fixtures.yaml` (throwaway Postgres, a **dex** OIDC provider — the app performs discovery at boot, so a fixture provider is mandatory, plus the two dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=600` for the JVM-plus-Liquibase start. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/urlaubsverwaltung/` holds `test-values.yaml` (`op://` refs against the stub CRD, host `uv.ci.local`, `clusterIssuer: ci`), `fixtures.yaml` (throwaway Postgres, a **dex** OIDC provider — the app performs discovery at boot, so a fixture provider is mandatory, plus the two dummy secrets) and `settings.env` with `INSTALL_TIMEOUT=600` for the JVM-plus-Liquibase 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/urlaubsverwaltung.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, §10 and §12.3 of which it measured - [Chart style guide](Chart-Style-Guide) — the rules this chart implements, §10 and §12.3 of which it measured
+55 -3
@@ -8,7 +8,7 @@ Deploys [VoucherVault](https://github.com/l4rm4nd/VoucherVault), a Django applic
| Versions | not listed on purpose — read the current pair off `voucher-vault/Chart.yaml`, see [Chart index](Charts) | | Versions | not listed on purpose — read the current pair off `voucher-vault/Chart.yaml`, see [Chart index](Charts) |
| Workloads | StatefulSet (1 replica) plus an `init-static` init container | | Workloads | StatefulSet (1 replica) plus an `init-static` init container |
| Storage | one RWO PVC mounted at `/opt/app/database`; three emptyDirs for the read-only root filesystem | | Storage | one RWO PVC mounted at `/opt/app/database`; three emptyDirs for the read-only root filesystem |
| Ingress | yes, `ingress.domain` required **always**, `ingress.clusterIssuer` while enabled | | Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`). `ingress.domain` required **always**, `ingress.clusterIssuer` while the Ingress is enabled — see [Routing: Ingress or Gateway API](#routing-ingress-or-gateway-api) |
| External dependencies | Postgres, Redis (Celery broker), an OIDC provider | | External dependencies | Postgres, Redis (Celery broker), an OIDC provider |
| Secrets | 3 × `OnePasswordItem` | | Secrets | 3 × `OnePasswordItem` |
| Install-test | `ci/voucher-vault/` (fixtures + test values) | | Install-test | `ci/voucher-vault/` (fixtures + test values) |
@@ -48,6 +48,22 @@ ingress:
clusterIssuer: letsencrypt 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: <the public hostname> # 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. `ingress.domain` is still required — it is Django's `DOMAIN` and therefore its only `ALLOWED_HOSTS` entry besides `127.0.0.1`. 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 — and additionally warns when `voucherVault.env` has dropped a required entry. Read it — it is the per-release version of this page. `helm install` prints the chart's `NOTES.txt`, which renders the same information against the values actually set — and additionally warns when `voucherVault.env` has dropped a required entry. Read it — it is the per-release version of this page.
### What the 1Password items must contain ### What the 1Password items must contain
@@ -114,16 +130,50 @@ Deliberately **not** null-safe: an erased `storage` block yields an invalid PVC
| Key | Type | Default | Description | | Key | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `ingress.enabled` | bool | `true` | Gates the Ingress object. Switching it off makes `clusterIssuer` unnecessary — but **not** `domain`. | | `ingress.enabled` | bool | `true` | Gates the Ingress object. **Mutually exclusive with `httpRoute.enabled`.** Switching it off makes `clusterIssuer` unnecessary — but **not** `domain`. |
| `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host *and* Django's `DOMAIN`: the only `ALLOWED_HOSTS` entry besides `127.0.0.1`, and the base of `CSRF_TRUSTED_ORIGINS`. | | `ingress.domain` | string | `null` | **Required, always** — also with `ingress.enabled: false`. It is the Ingress host *and* Django's `DOMAIN`: the only `ALLOWED_HOSTS` entry besides `127.0.0.1`, and the base of `CSRF_TRUSTED_ORIGINS`. |
| `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. | | `ingress.clusterIssuer` | string | `null` | **Required while the Ingress is enabled.** cert-manager `ClusterIssuer`. |
| `ingress.className` | string | `nginx` | Ingress class. | | `ingress.className` | string | `nginx` | Ingress class. |
| `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. | | `ingress.annotations` | object | `{}` | Merged with the cert-manager annotation the chart always sets. **No counterpart in Gateway mode**, and nothing this chart needs lives here. |
TLS is stored in `tls-<release>-voucher-vault-ingress`. TLS is stored in `tls-<release>-voucher-vault-ingress`.
### `httpRoute`
The Gateway-API alternative to the Ingress, **off by default**. Added with group 1 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>-voucher-vault-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is **only** the `HTTPRoute`: one rule, `PathPrefix: /`, one `backendRefs` entry pointing at `<release>-voucher-vault-service` on port **80**. Django serves the UI, `/oidc/*` and the admin from one process.
## Operating notes ## 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.
**`ALLOWED_HOSTS` does not follow the route, and the probes cannot tell you.** Django's `DOMAIN` is built from `ingress.domain`, and that is the only name besides `127.0.0.1` it accepts. A hostname the `HTTPRoute` publishes which `ingress.domain` does not name is answered with **400 before a single view runs** — and all three probes stay green while it happens, because they deliberately carry a `Host` header of `127.0.0.1` so that probing survives every domain change. So `httpRoute.hostnames` and `ingress.domain` have to be set together; a migration over a second hostname is not a route-only change here.
**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>-voucher-vault-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.
### `voucherVault.env` is the complete environment, not an additive block ### `voucherVault.env` is the complete environment, not an additive block
This is the chart's sharpest deviation from [style guide §10](Chart-Style-Guide#10-environment-variables). For this chart `voucherVault.env` is the container's **whole** env list: a values file that sets it overwrites everything, including the entries that wire up the three Secrets. `NOTES.txt` checks for `DOMAIN`, `SECRET_KEY`, `REDIS_URL`, `DB_ENGINE`, `POSTGRES_HOST` and `POSTGRES_DB` after every install or upgrade and prints which of them went missing — but the render itself succeeds, so read it. To change one entry, copy the full list out of the chart's `values.yaml` and edit the copy. This is the chart's sharpest deviation from [style guide §10](Chart-Style-Guide#10-environment-variables). For this chart `voucherVault.env` is the container's **whole** env list: a values file that sets it overwrites everything, including the entries that wire up the three Secrets. `NOTES.txt` checks for `DOMAIN`, `SECRET_KEY`, `REDIS_URL`, `DB_ENGINE`, `POSTGRES_HOST` and `POSTGRES_DB` after every install or upgrade and prints which of them went missing — but the render itself succeeds, so read it. To change one entry, copy the full list out of the chart's `values.yaml` and edit the copy.
@@ -228,6 +278,8 @@ Rotating `secretKey` logs every user out.
`ci/voucher-vault/test-values.yaml` holds the three `op://` refs (against the stub CRD) and `vv.ci.local` with issuer `ci`. `ci/voucher-vault/fixtures.yaml` stands up throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the three Secrets the 1Password operator would otherwise materialize — so the install-test exercises the full OIDC wiring, not just the render. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD). `ci/voucher-vault/test-values.yaml` holds the three `op://` refs (against the stub CRD) and `vv.ci.local` with issuer `ci`. `ci/voucher-vault/fixtures.yaml` stands up throwaway Postgres and Redis, a **dex** OIDC provider with a static client, and the three Secrets the 1Password operator would otherwise materialize — so the install-test exercises the full OIDC wiring, not just the render. 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-test installs 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 (`ci/gateway-api-crds.sh`, pinned, applied `--server-side`), which is where a malformed `parentRefs` or `backendRefs` is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead `ci/httproute/voucher-vault.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 [CI/CD](CI-CD).
## See also ## See also
- [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the env-block deviation it carries - [Chart style guide](Chart-Style-Guide) — the rules this chart implements, and the env-block deviation it carries