feat(bulwark): Gateway-API as an alternative to the Ingress

Group 3 of the #114 sweep. One route, one rule, at the root - and THREE
EXPECTATIONS ABOUT THIS CHART DID NOT SURVIVE CONTACT WITH IT:

  - #114 says bulwark has TWO Ingress objects. It has ONE. keycloak has two.
  - #114's measured table lists bulwark under rewrite-target. It has no
    rewrite-target annotation and CANNOT have one: NEXT_PUBLIC_BASE_PATH is
    compiled into the bundle, so the application cannot be served under a
    sub-path, and stripping a prefix at the proxy would yield 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. A grep for the
    string finds the refutations and counts them as occurrences.
  - ci/run-httproute-match-test.sh said in its header that bulwark was the
    second rewrite-target chart and would meet the #118 prefix/redirect
    assurance when group 3 landed. Group 3 landed and it does not: with no
    URLRewrite on a prefix other than "/", the assurance's precondition is
    never met here. The header is corrected rather than left standing, and it
    now says freesailarr is the only chart the pairing applies to.

So no redirect rule is added, and none is needed.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT rather than inferred. On
2026-10-12 the live Ingress on webmail.bekemeyer-klein.de carried

    nginx.ingress.kubernetes.io/proxy-body-size: 25m

supplied by the OPERATOR through ingress.annotations - values.yaml recommends
exactly that value and explains why the chart cannot pick it, so the chart
sets nothing and a reader of this repository alone would not find it. It is
the single annotation this installation depends on, and the Gateway API has no
body-size counterpart: implementation policy, not routing. Per decisions 1 and
2 of #114 it is NAMED, not rebuilt - in values.yaml, in the schema and in the
Gateway branch of NOTES.txt, in this application's own terms: an attachment
above the Gateway's default is rejected before Bulwark sees a byte, the 413
comes from the proxy, and it arrives after the user has written the mail and
attached the file.

CORRECTED CLAIM, the same one garage and calibre-web-automated needed: the
Ingress template, the bulwark.publicUrl helper and the schema all stated that
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain. The
HTTPRoute reads it, so the claim held only while both modes were off. All
three now say so, and the route carries a required() of its own.

bulwark.publicUrl follows the ACTIVE mode instead of always printing
ingress.domain. The two differ exactly when httpRoute.hostnames is set, which
is how a migration off the Ingress runs - and printing the old host there
would hand the operator an OIDC redirect URI for the hostname being migrated
away from, a failure that surfaces as a rejected login at the identity
provider and says nothing about this chart.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/bulwark/test-values.yaml.

Part of #114
This commit is contained in:
jklein committed 2026-10-12 20:49:56 +00:00
1 parent f97e1e6d52
commit f6eeff87c2
9 files changed
+416 -30

No files matched your search

+87 -10
View File
@@ -1,4 +1,16 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
@@ -11,17 +23,75 @@ The messages and the accounts belong to the Stalwart server at
If that server is away, this release is still Ready and still green - see
"WHAT THE PROBES DO NOT TELL YOU" below.
ENDPOINTS
{{ if .Values.ingress.enabled }} Web: {{ $url }}
{{ else }} Web: no Ingress (ingress.enabled: false) - in-cluster only
ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
{{ else }} Web: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
both false) - in-cluster only
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }}
{{ if .Values.ingress.enabled }} Admin: {{ $url }}/admin/login
{{ else }} Admin: /admin/login on the in-cluster address above. With no Ingress, reach it with
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
{{ else }} Admin: /admin/login on the in-cluster address above. With nothing published, reach it with
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }}
and open http://localhost:3000/admin/login
{{ end }}{{ if .Values.ingress.enabled }}
{{ end }}{{ if or .Values.ingress.enabled $httpRoute.enabled }}
Bulwark is served at the ROOT of that host and cannot be moved under a path:
NEXT_PUBLIC_BASE_PATH is compiled into the image.
NEXT_PUBLIC_BASE_PATH is compiled into the image.{{ if $httpRoute.enabled }} That is also why the
HTTPRoute carries no URLRewrite filter - there is no prefix to strip.{{ end }}
{{ end }}{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders ONE HTTPRoute and NOTHING else of the routing path - no
Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
Three things therefore belong to the cluster, and if any is missing the
hostname above simply does not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
for this release.
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
In Ingress mode an operator sets nginx.ingress.kubernetes.io/proxy-body-size
through ingress.annotations - this installation runs with 25m - because
nginx's default of 1m rejects almost every attachment. IN THIS MODE THAT IS
GONE: the Gateway API has no body-size field, it is implementation policy
rather than routing, and this chart renders no policy object on purpose (it
would bind the chart to one Gateway implementation).
So the limit in force is 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: a user has written the mail, attached
the file and pressed send, and the 413 comes back 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, in your implementation's own object,
BEFORE you point DNS at it - and size it to what the MAIL SERVER accepts
(the Stalwart release above), not 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.
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
{{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD. A missing or misnamed
@@ -244,9 +314,16 @@ WHAT THIS CHART DOES NOT RENDER
- No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid
every voluntary eviction and make `kubectl drain` hang forever on a pod
nothing may move (README, guideline 11).
- No sub-path Ingress, and no value that could add one - the base path is
compiled into the image.
- No sub-path Ingress and no sub-path route, and no value that could add
one - the base path is compiled into the image. Hence also no URLRewrite
filter in Gateway mode.
- No proxy-body-size annotation. Attachment uploads go through the Ingress and
nginx's default of 1m rejects larger ones with a 413 that reads like an
application fault; the right number is the mail server's limit, which this
chart does not know. Set it in ingress.annotations.
chart does not know. Set it in ingress.annotations{{ if $httpRoute.enabled }} - except that in GATEWAY
MODE there is no annotation to set and no counterpart at all, which is the
section above{{ end }}.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.