bulwark: the Gateway API as an alternative to the Ingress (#114)
One route, one rule, at the root - and three expectations #114 had of this chart that it does not meet, written down because they are what somebody will copy from. It has ONE Ingress object rather than two; it has NO rewrite-target and cannot have one, because NEXT_PUBLIC_BASE_PATH is compiled into the bundle; and the #118 prefix/redirect assurance therefore has no precondition to meet here. Every occurrence of the string rewrite-target in the chart is an explanation of why the annotation does not help, which is how a grep produced the opposite impression. The loss in Gateway mode is proxy-body-size: 25m, measured on the live Ingress on 2026-10-12 and set by the operator rather than by the chart - values.yaml only recommends it. The Gateway API has no body-size field, so the limit becomes the Gateway's, and the failure arrives as a 413 from the proxy after the user has written the mail and attached the file. Corrected claim: this page said nothing outside the Ingress template functionally reads ingress.domain. The HTTPRoute reads it, so gating the Ingress off frees clusterIssuer but frees domain only with both modes off.
1 file changed
+59
-7
+59
-7
@@ -12,7 +12,7 @@ Deploys [Bulwark Webmail](https://bulwarkmail.org/) — a JMAP webmail client (m
|
||||
| Versions | not listed on purpose — read the current pair off `bulwark/Chart.yaml`, see [Chart index](Charts). Note that the registry publishes **every release twice**, `1.12.0` and `v1.12.0` for the same image; the chart pins the form **without** the `v`, and [Renovate](Renovate) keeps it in that family |
|
||||
| Workloads | one StatefulSet, **1** replica — capped at 1, see [Storage](#storage-one-volume-and-why) |
|
||||
| Storage | **one** PVC at `/app/data`, carrying all four of Bulwark's directories as subdirectories |
|
||||
| Ingress | one, at the **root of a hostname**. A sub-path is impossible — see [Three options are compiled in](#three-options-are-compiled-into-the-image) |
|
||||
| Routing | **two mutually exclusive modes**, Ingress (default) or Gateway API (`httpRoute.enabled`) — **one** object in either mode, at the **root of a hostname**. A sub-path is impossible, which is also why the route carries no rewrite filter — see [Three options are compiled in](#three-options-are-compiled-into-the-image) and [Routing](#routing-ingress-or-gateway-api). In Gateway mode the **upload limit is not enforced** |
|
||||
| External dependencies | a Stalwart (or other JMAP) server, and an OIDC provider ([keycloak](keycloak)) while `bulwark.oauth.enabled` is true. **No database, no cache, no broker** |
|
||||
| Secrets | 2 × `OnePasswordItem`, three fields in total — two of them as **files**, which is the better path [garage](garage) and [keycloak](keycloak) did not have |
|
||||
| Network | ClusterIP Service on 3000 (`http`) plus a headless Service the StatefulSet needs for its `serviceName`. No NetworkPolicy |
|
||||
@@ -24,7 +24,7 @@ Deploys [Bulwark Webmail](https://bulwarkmail.org/) — a JMAP webmail client (m
|
||||
- **A reachable JMAP endpoint.** In-cluster that is a Stalwart release's `http` Service; a published one is its HTTPS URL. It does **not** have to be reachable at install time — Bulwark connects per request — but the value must be set.
|
||||
- **1Password operator** in the cluster plus **two** items — see [What the 1Password items must contain](#what-the-1password-items-must-contain).
|
||||
- **An OIDC client, registered BEFORE the first install**, while `bulwark.oauth.enabled` is true. This is not a follow-up step: with `bulwark.oauth.only` (the default) the password form is hidden, so without a working client **the first sign-in is impossible**. And read [the redirect URI carries the UI language](#the-redirect-uri-carries-the-ui-language) before you register it — a single redirect URI is the likeliest reason a first sign-in fails.
|
||||
- **One DNS name and a cert-manager cluster issuer.**
|
||||
- **One DNS name and a cert-manager cluster issuer.** In **Gateway mode** the cluster issuer is not needed from this release: the certificate sits on the Gateway's listener. What is needed instead is the **Gateway-API CRDs** plus a Gateway whose listener admits routes from this namespace — and a body-size limit configured next to that Gateway, because this chart's recommendation does not reach it ([why](#routing-ingress-or-gateway-api)).
|
||||
- **A StorageClass**, or the cluster's default.
|
||||
|
||||
## Installation
|
||||
@@ -225,11 +225,57 @@ Deleting `admin.json` in the same directory resets the admin password to whateve
|
||||
|
||||
| Value | Default | Notes |
|
||||
|---|---|---|
|
||||
| `ingress.enabled` | `true` | Gating it off frees **both** `domain` and `clusterIssuer` — checked in both directions; nothing outside the Ingress template functionally reads the domain |
|
||||
| `ingress.domain` | `null` | Required while enabled. Bulwark is served at the **root** of it |
|
||||
| `ingress.clusterIssuer` | `null` | Required while enabled |
|
||||
| `ingress.className` | `nginx` | |
|
||||
| `ingress.annotations` | `{}` | Merged with the cert-manager annotation. **Consider `proxy-body-size`** — see the trap |
|
||||
| `ingress.enabled` | `true` | **Mutually exclusive with `httpRoute.enabled`.** Gating it off frees `clusterIssuer`; it frees `domain` only if `httpRoute` is off as well — the route reads the domain too. *This page used to say nothing outside the Ingress template functionally reads the domain; that was true until the Gateway support landed* |
|
||||
| `ingress.domain` | `null` | Required while **either** mode is on. Bulwark is served at the **root** of it. Also the `HTTPRoute`'s hostname while `httpRoute.hostnames` is empty, and the host `NOTES.txt` builds the OIDC redirect URI from |
|
||||
| `ingress.clusterIssuer` | `null` | Required while the Ingress is enabled. Unused *and* unnecessary in Gateway mode |
|
||||
| `ingress.className` | `nginx` | No counterpart in Gateway mode — the class sits on the Gateway |
|
||||
| `ingress.annotations` | `{}` | Merged with the cert-manager annotation. **Consider `proxy-body-size`** — see the trap. **No counterpart in Gateway mode**: the Gateway API has no annotation channel, so this block is inert there, and for this chart that is the one annotation the installation actually runs on |
|
||||
|
||||
### `httpRoute`
|
||||
|
||||
The Gateway-API alternative to the Ingress, **off by default**. Added with group 3 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)).
|
||||
|
||||
| Value | Default | Notes |
|
||||
|---|---|---|
|
||||
| `httpRoute.enabled` | `false` | Renders `<release>-bulwark-httproute` instead of the Ingress. **Mutually exclusive with `ingress.enabled`** — with both `true` the render aborts naming both values |
|
||||
| `httpRoute.parentRefs` | `[]` | 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` | `[]` | Empty means `[ingress.domain]`, so the hostname has a single source of truth. **If you set it, register that origin with the identity provider first**: `NOTES.txt` derives the OIDC redirect URI from the active mode's hostname, and a login returning to an unregistered origin is rejected at the provider, which says nothing about this chart |
|
||||
|
||||
One rule, at `/`, with **no `URLRewrite` filter** — the application cannot be served under a sub-path at all, so there is nothing to strip.
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
| `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. For Bulwark it also breaks the login: the OIDC redirect URI registered with the provider names **one** origin, so a session that starts on one data path and returns on the other fails on the state cookie. **A migration runs over a second hostname** — and that second origin has to be registered as a redirect URI too |
|
||||
| `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 |
|
||||
| `bulwark.publicUrl` follows the **active** mode | the helper `NOTES.txt` prints the access URL and the OIDC redirect URI from. The two modes' hostnames differ exactly when `httpRoute.hostnames` is set, which is how a migration runs — printing `ingress.domain` there would hand the operator a redirect URI for the host being migrated *away from*, and that failure surfaces as a rejected login at the provider rather than as anything about this chart |
|
||||
|
||||
### What this chart is *not*, and #114 said otherwise
|
||||
|
||||
Worth having written down, because the issue's measured table and the CI test's own header both expected something else of this chart:
|
||||
|
||||
- **It has ONE Ingress object, not two.** `keycloak` is the chart with two.
|
||||
- **It has no `rewrite-target` annotation and cannot have one.** `NEXT_PUBLIC_BASE_PATH` is compiled into the bundle ([Three options are compiled in](#three-options-are-compiled-into-the-image)), so a sub-path deployment needs a rebuilt image, and stripping a prefix at the proxy would produce a page that loads and then fetches everything from the wrong place. Every occurrence of the string `rewrite-target` in this chart — `values.yaml`, the schema, the Ingress template — is an explanation of exactly that, and a `git grep` for the string counted those refutations as occurrences.
|
||||
- **Consequently the #118 prefix/redirect assurance does not apply here.** That check pairs a prefix-stripping `URLRewrite` with an `Exact`-match `RequestRedirect` on the bare prefix; with no rewrite on a prefix other than `/`, its precondition is never met. There is no bare prefix to redirect.
|
||||
|
||||
### What Gateway mode costs this chart
|
||||
|
||||
Measured on the live Ingress on 2026-10-12:
|
||||
|
||||
```
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 25m
|
||||
```
|
||||
|
||||
**The chart does not set it** — `values.yaml` recommends exactly that value and explains why the chart cannot pick it (the right number is the mail server's, which this chart does not know), so the 25m is the operator's, supplied through `ingress.annotations`. It is the single annotation this installation depends on, and in Gateway mode it is gone: the Gateway API has no body-size field, that is implementation policy rather than routing, and the chart renders no implementation-specific policy object on purpose ([style guide §4](Chart-Style-Guide#4-ingress-and-gateway-api)).
|
||||
|
||||
So the limit in force becomes **the Gateway's**, whatever it happens to be, and no value of this chart has any say in it. The failure mode on a webmail client is worth picturing before it happens: the user has written the mail, attached the file and pressed send, and the **413 comes from the proxy**. It reads like Bulwark being broken, nothing mentions a limit, and the draft is the thing at risk.
|
||||
|
||||
**Set the equivalent next to the Gateway before pointing DNS at it**, and size it to what the **mail server** accepts ([stalwart](stalwart)) rather than to what looks generous here — a webmail client that accepts a 40 MB attachment the mail server will refuse has only moved the failure later. The Gateway branch of `NOTES.txt` prints this.
|
||||
|
||||
## Traps
|
||||
|
||||
@@ -402,6 +448,8 @@ ingress:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
```
|
||||
|
||||
That is what the live installation runs on, measured 2026-10-12 — the recommendation is not hypothetical. **In Gateway mode this annotation does not exist and has no counterpart**; the limit then comes from the Gateway and has to be set there. See [What Gateway mode costs this chart](#what-gateway-mode-costs-this-chart).
|
||||
|
||||
### `bulwark.env` is additive, and only the middle layer
|
||||
|
||||
Additive means additive: an entry repeating a name the chart already sets does not replace it. At install both reach the pod and the later one wins; on an **existing** release the pair collapses to the chart's own value, because Kubernetes merges `env` by name — and `helm upgrade` still reports success ([style guide §10](Chart-Style-Guide#10-environment-variables)).
|
||||
@@ -428,6 +476,10 @@ And the sharper caveat specific to this application: an environment variable is
|
||||
- **that the two file-form secret keys are spelled right.** The projected volume is `optional: true` by design, so a misspelled key yields a missing file and a healthy pod. Only `admin-password` is proven.
|
||||
- **that no emptyDir is superfluous.** A missing writable path fails loudly; an unnecessary one does not. That half stays open.
|
||||
|
||||
**The Gateway mode is held at render level**, by `ci/httproute/bulwark.yaml` plus `ci/run-httproute-match-test.sh`, which runs over every chart on every PR ([CI/CD](CI-CD#httproute-render-test)). The overlay switches this chart into Gateway mode; **2 assertions** — `parentRefs` not empty, and the `backendRefs` entry resolving to the serving Service of the same render **by port number**, because `backendRefs` takes a `PortNumber` and cannot name the port `http` the way the Ingress does. An overlay rather than a fixture change, because the install-test runs one configuration per chart and this chart's fixture installs the StatefulSet with its PVC and both vault items.
|
||||
|
||||
**What that does not cover**: that the hostname answers — no Gateway controller runs in CI, so no route reaches `Accepted: True` — and the `proxy-body-size: 25m` this installation depends on, which has no counterpart in that mode at all. That loss is documented in `values.yaml` and printed by `NOTES.txt`, and documentation is not something a test can assert. **Nothing in CI reads `NOTES.txt`**: `helm template` does not emit it and no script under `ci/` reads it, so its text carries review-only assurance.
|
||||
|
||||
## See also
|
||||
|
||||
- [stalwart](stalwart) — the mail server this is a client for. The two are only useful together.
|
||||
|
||||
Reference in new issue
Block a user