Files
helm-charts/stalwart/templates/stalwart.httproute.yaml
T
jklein cffc50e4ea feat(stalwart): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. ONE route - not one per node group. #114 describes
this chart as rendering "per node group"; its SERVICES and StatefulSets are,
the routing object is not and must not be. The route targets exactly
ingress.nodeGroup's own http Service and keeps both of the Ingress's guards,
because the WebUI's setup and management screens are not safe to spread over a
group that may not run the management tasks. ingress.nodeGroup is therefore
shared by both modes: it names where the management surface is published from,
which is not a property of the Ingress.

THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
anything of the TLS path does. stalwart.certificate.yaml covers STARTTLS on 25
and implicit TLS on 465/993; those connections pass through neither an ingress
controller nor a Gateway. So in Gateway mode ingress.domain is still required
(the certificate's dnsNames read it), ingress.clusterIssuer is still required
unless certificate.clusterIssuer is set, and the Certificate must NOT be
removed as part of a migration - deleting it drops SMTP and IMAP back to
Stalwart's self-signed certificate, with clients seeing a name mismatch while
every Kubernetes object stays green. Verified in the render: Gateway mode emits
Certificate + HTTPRoute and no Ingress.

WHAT THIS MODE DROPS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
all of it operator-supplied through ingress.annotations (this chart sets none
of it):

    enable-cors: true
    cors-allow-origin:  https://webmail.bekemeyer-klein.de
    cors-allow-methods: GET, POST, OPTIONS
    cors-allow-headers: Authorization, Content-Type

That origin is BULWARK, the webmail chart in this same group. Without the CORS
configuration the browser-side webmail cannot call this server's JMAP endpoint
at all, and the failure is entirely client-side: a console error, nothing in
the server's logs.

AND "no counterpart" WOULD HAVE BEEN WRONG. Measured by grepping the CRDs this
repo pins (gateway-api v1.6.3 standard channel, ci/gateway-api-crds.sh) rather
than inferred: HTTPRoute's filters carry a full 'cors' field (GEP-1767) with
allowOrigins/allowMethods/allowHeaders/allowCredentials/maxAge, and its
documented level - verbatim from that CRD - is "Support: Extended".
responseHeaderModifier is in the same CRD and is also Extended, not Core.

The chart still does not render it, for three reasons in order:

  1. The chart has NO CORS values. The live configuration is the operator's,
     and the chart sets no CORS annotations in Ingress mode either - so
     rendering a filter means inventing a values surface that would exist in
     Gateway mode only. That asymmetry is its own defect and its own work.
  2. Extended support is not support. A rejected route is rejected WHOLE,
     which would take the WebUI and JMAP down together (the reasoning #119
     reached for statusCode); and the observed failure is quieter - Cilium
     accepts filters.type: CORS and forwards the preflight upstream anyway
     (cilium/cilium#45272), i.e. Accepted: True with the behaviour absent.
  3. Half a CORS is worse than none, and the translatable half is the useless
     one HERE. A responseHeaderModifier can add the Access-Control-Allow-*
     headers; the PREFLIGHT has to be answered BY THE GATEWAY, which the CORS
     filter's own contract states and a bare header filter cannot do. Because
     the allowed headers include Authorization, EVERY webmail request is
     preflighted - so the expressible half is exactly the half that does not
     help.

Named, not rebuilt (decision 2 of #114), in values.yaml, in the schema and in
the Gateway branch of NOTES.txt, with the operational instruction: configure
CORS at the Gateway with that implementation's own support BEFORE moving this
host, and test it from the webmail in a browser - curl sends no preflight, so
it passes while the webmail fails.

NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) where it
asked ingress.enabled, and two paragraphs that named the ingress controller
specifically now name the Gateway in that mode: the auto-ban step (switching
API does not avoid or reset it - one proxy address instead of another) and the
NetworkPolicy paragraph, where the namespace to allow is the Gateway's data
plane rather than an ingress controller's.

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

Part of #114
2026-10-12 21:10:00 +00:00

166 lines
9.9 KiB
YAML

{{/*
HTTPRoute - the Gateway-API alternative to stalwart.ingress.yaml (Gitea #114).
ONE ROUTE, NOT ONE PER NODE GROUP, and that is worth saying because #114
describes this chart as rendering "per node group". Its SERVICES and
StatefulSets are per group; the routing object is not and must not be. The
Ingress targets exactly ONE group (ingress.nodeGroup) through that group's own
http Service, because the WebUI's setup and management screens are not safe to
spread over a group that may not run the management tasks. The route keeps that
property, including the render abort when the named group does not expose
"http".
WHAT STAYS IN THIS MODE, AND IT IS THE ONLY CHART IN THE SWEEP WHERE ANYTHING
DOES: the MAIL certificate. stalwart.certificate.yaml is not the Ingress
certificate - it covers STARTTLS on 25 and implicit TLS on 465/993, and those
connections never pass through an ingress controller or a Gateway. So
certificate.enabled keeps rendering a Certificate here, ingress.domain stays
required (the certificate's dnsNames read it) and ingress.clusterIssuer stays
required unless certificate.clusterIssuer is set. Do NOT "clean up" the
Certificate when switching to Gateway mode: SMTP and IMAP would fall back to
whatever self-signed certificate Stalwart generates, and clients would see a
name mismatch while every Kubernetes object stayed green.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate FOR THE HTTP HOSTNAME, and no implementation-specific policy
object - the subset that looks identical on nginx-gateway-fabric, Cilium, Envoy
Gateway and Istio (decision 1 of #114). The HTTP hostname's certificate belongs
on the Gateway's listener; the mail certificate stays here, which is the
distinction above.
And the Gateway API routes HTTP only, so SMTP, IMAP and ManageSieve are as far
outside this object as they are outside the Ingress - they keep reaching the
pods through the per-port NodePort Services. A Gateway could in principle carry
them as TLSRoute/TCPRoute listeners, which this chart does not render and which
would be a separate decision.
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND THIS IS THE ONE PLACE
IN GROUP 3 WHERE THE LOSS BREAKS A SECOND CHART OF THE SAME GROUP
On 2026-10-12 the live Ingress (jk-cluster, namespace stalwart,
mail.bekemeyer-klein.de) carried, all of it OPERATOR-supplied through
ingress.annotations - this chart sets none of it:
nginx.ingress.kubernetes.io/enable-cors: true
nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
That origin is BULWARK, the webmail chart in this same group. The CORS
configuration is the whole reason the browser-side webmail may call this
server's JMAP endpoint at all, so it is not decoration: without it the webmail
stops working, with a console error and no server-side symptom.
WHAT THE GATEWAY API HAS FOR IT, MEASURED AGAINST THE PINNED CRDs RATHER THAN
ASSUMED. Both halves exist in the API, and neither is promisable by this chart:
- A full CORS FILTER exists. In the standard channel this repository pins
(gateway-api v1.6.3, ci/gateway-api-crds.sh), HTTPRoute's filters carry a
`cors` field with allowOrigins, allowMethods, allowHeaders,
allowCredentials and maxAge - GEP-1767. Grepped out of the downloaded CRD,
not inferred. Its documented support level, verbatim from that CRD, is
"Support: Extended".
- responseHeaderModifier is in the same CRD and is ALSO "Support: Extended",
not Core.
So "no counterpart" would be the wrong thing to write down. The accurate
statement is that there is a counterpart and this chart still does not render
it, for three reasons, in order:
1. THE CHART HAS NO CORS VALUES AT ALL. The live configuration is the
operator's: a specific origin, a specific method list. Rendering a CORS
filter means inventing a values surface in a sweep PR - and one that
would exist in Gateway mode only, because this chart sets no CORS
annotations in Ingress mode either. That asymmetry is its own defect and
its own piece of work.
2. EXTENDED SUPPORT IS NOT SUPPORT. #119 established the shape of this: an
implementation that does not support an Extended value must report it in
the route's status, and a rejected route is rejected WHOLE - here that
would take the WebUI and JMAP down together. Worse, the observed failure
is quieter than that: Cilium accepts `filters.type: CORS` and still
forwards the browser's preflight upstream
(cilium/cilium#45272), i.e. the object is Accepted and the behaviour is
absent. A chart cannot check which implementation it landed on.
3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
ONE HERE. A responseHeaderModifier can add the three
Access-Control-Allow-* headers to a response, but the PREFLIGHT is a
different thing: the browser sends OPTIONS first and the gateway - not the
backend - has to answer it. The CORS filter's own contract says so (a path
with a CORS filter MUST NOT pass the preflight upstream), which is exactly
what a bare header modifier cannot do. And this installation's header list
includes Authorization, which makes EVERY webmail request preflighted: the
half that is expressible without the CORS filter is precisely the half
that does not help. Rendering it would produce a setup that looks
configured and fails on the first real call.
So: named, not rebuilt (decision 2 of #114), in the Gateway branch of
NOTES.txt, with the instruction to configure CORS at the Gateway - via that
implementation's CORS support - BEFORE moving this host, and to test it from
the webmail rather than with curl, because curl sends no preflight.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Stalwart there is a second edge to it: the server's auto-ban sees the proxy's address and bans it, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and note that the server hostname configured IN THE DATABASE decides where the WebUI tells administrators to go, which no chart value can change." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $group := include "stalwart.ingressNodeGroup" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $spec := index $nodes $group -}}
{{- if not (has "http" $spec.exposedPorts) -}}
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the HTTPRoute to point at. The value is shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress." $group $group) -}}
{{- end -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
read outside the routing templates in this chart - the mail Certificate's
dnsNames - so it already carries a required() there and is mandatory in every
mode, including with both modes off. This required() is the route's own, so the
message names the route.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "stalwart.fullname" . }}-httproute
labels:
app: {{ include "stalwart.fullname" . }}
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the http
Service of the "{{ $group }}" group ONLY - never a port-label selector,
for the reason in the header. The port is the Service's port NUMBER and
not its name "http": backendRefs takes a PortNumber, so unlike the
Ingress this reference cannot be made by name. It comes from
ports.http.containerPort, which is the same value the Service renders its
port from, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port: {{ int (index $ports "http").containerPort }}
{{- end }}