feat: Gateway-API as an alternative to the Ingress in the six special shapes (group 3) #120
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/stalwart/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart, and this chart's fixture brings up
|
||||
# the node groups against a throwaway Postgres. Trading its Ingress for a CRD
|
||||
# schema check that cyberchef's fixture already performs against the real
|
||||
# Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
|
||||
# route is held here, at render level, which is where its silent failure modes
|
||||
# live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
|
||||
# HTTP routing switch and defaults to TRUE, so without this line the chart
|
||||
# aborts its own render with both modes on.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS:
|
||||
#
|
||||
# - ingress.domain comes from the fixture, so the route renders with its
|
||||
# hostname from that single source of truth. httpRoute.hostnames stays
|
||||
# empty on purpose: the fallback is the path every installation will
|
||||
# actually use.
|
||||
# - ingress.nodeGroup comes from the fixture too, so the route is checked to
|
||||
# point at THAT GROUP'S http Service and no other. This chart is the one in
|
||||
# group 3 where the backend is group-scoped, and the ONE-route-not-one-
|
||||
# per-group property is exactly what would be easy to get wrong on a copy.
|
||||
# - the backendRef is checked against the per-group http Service of the same
|
||||
# render, by PORT NUMBER (ports.http.containerPort) rather than by the name
|
||||
# "http" the Ingress uses, because backendRefs takes a PortNumber. Note
|
||||
# that this chart's NodePort Services publish a DIFFERENT number
|
||||
# (ports.<name>.nodePort) than the container binds, so naming the wrong one
|
||||
# here would be a plausible mistake - this overlay is what catches it.
|
||||
#
|
||||
# No URLRewrite filter is rendered by this chart in either mode, so the #118
|
||||
# prefix/redirect assurance has no precondition to meet here.
|
||||
#
|
||||
# NOT covered here, and none of it can be:
|
||||
#
|
||||
# - that the hostname answers. No Gateway controller runs in CI and no route
|
||||
# reaches "Accepted: True".
|
||||
# - THE CORS CONFIGURATION, which is the thing this chart actually loses in
|
||||
# this mode and the one that breaks the bulwark webmail. It lives in the
|
||||
# operator's ingress.annotations, the chart has no values for it, and the
|
||||
# Gateway API's own CORS filter is Extended-support with observed
|
||||
# implementation variance - all of which is documented in values.yaml and
|
||||
# printed by NOTES.txt. Documentation is not something a test can assert.
|
||||
# - that SMTP/IMAP still work. They never went through the Ingress and do not
|
||||
# go through the route; the mail Certificate that serves them keeps being
|
||||
# rendered in this mode, which is this chart's distinguishing feature in
|
||||
# the sweep, and no render test looks at whether it was left alone.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
+1
-1
@@ -25,7 +25,7 @@ type: application
|
||||
# byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml
|
||||
# - measured, both empty diffs. New optional values with unchanged defaults is
|
||||
# exactly the minor case (README, "Versioning").
|
||||
version: 1.1.1+upv0.16.25
|
||||
version: 1.2.0+upv0.16.25
|
||||
appVersion: "v0.16.25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
{{- /*
|
||||
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.
|
||||
*/ -}}
|
||||
{{- $release := include "stalwart.fullname" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- /*
|
||||
"Published" rather than "has an Ingress": the two modes are alternatives, and
|
||||
the paragraphs below that asked ingress.enabled were really asking whether the
|
||||
WebUI and JMAP are reachable from outside at all (Gitea #114).
|
||||
*/ -}}
|
||||
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
{{- $ingressGroup := "" -}}
|
||||
{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
|
||||
{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
|
||||
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
|
||||
@@ -140,14 +158,18 @@ FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
|
||||
'Cluster role "<name>" not found in registry', and it is printed AFTER the
|
||||
listeners are already bound.
|
||||
|
||||
4. Configure HTTP forwarding before the auto-ban notices the Ingress.
|
||||
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP
|
||||
4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
|
||||
Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP
|
||||
request, not the client's. Sooner or later the auto-ban bans that one
|
||||
address and the WebUI and JMAP go dark for everyone at once. The PROXY
|
||||
protocol is not the fix for HTTP: enable "forwarded" handling with
|
||||
X-Forwarded-For evaluation, and discard forwarding headers the client
|
||||
brought itself. The outage arrives weeks later and looks like a server
|
||||
fault, which is why this is a step and not a tip.
|
||||
{{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not
|
||||
reset it: it is one proxy address instead of another, and whether the
|
||||
Gateway sends an X-Forwarded-For at all is that implementation's business,
|
||||
not something this route can state.{{ end }}
|
||||
|
||||
5. Point the TLS object at the mounted certificate.
|
||||
{{- if .Values.certificate.enabled }}
|
||||
@@ -295,7 +317,7 @@ DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
WEB UI AND JMAP
|
||||
WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
|
||||
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
|
||||
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
|
||||
@@ -305,10 +327,101 @@ WEB UI AND JMAP
|
||||
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
|
||||
database (step 3). Between finishing setup and creating that listener the
|
||||
Ingress resolves to a port that answers nothing.
|
||||
{{- else if $httpRoute.enabled }}
|
||||
|
||||
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- range $hostnames }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
→ node group "{{ $ingressGroup }}", one route, not one per group
|
||||
|
||||
This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing
|
||||
else of the HTTP routing path — no Gateway, no class, no certificate for this
|
||||
hostname, no policy object. Three things therefore belong to the cluster:
|
||||
|
||||
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 shows only in
|
||||
the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is a working attachment; an empty status means no
|
||||
controller has looked at the route at all.
|
||||
3. The TLS certificate for the HTTP hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is not needed FOR THAT — but read the next
|
||||
paragraph before removing it.
|
||||
|
||||
THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP.
|
||||
{{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders
|
||||
{{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }}
|
||||
That object has nothing to do with the Ingress or the Gateway: it covers
|
||||
STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS
|
||||
ports, and those connections pass through no HTTP proxy at all. So in this mode
|
||||
ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and
|
||||
ingress.clusterIssuer is still required unless certificate.clusterIssuer is
|
||||
set. Deleting the Certificate as part of a migration drops SMTP and IMAP back
|
||||
to Stalwart's self-signed certificate: clients see a name mismatch while
|
||||
every Kubernetes object in this release stays green.
|
||||
|
||||
SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not
|
||||
routed by the Ingress. They keep arriving through the per-port NodePort
|
||||
Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this
|
||||
chart renders neither.
|
||||
|
||||
AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL.
|
||||
|
||||
This installation's live Ingress carries, set by the operator through
|
||||
ingress.annotations and NOT by this chart:
|
||||
|
||||
nginx.ingress.kubernetes.io/enable-cors: true
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: https://<the webmail host>
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
||||
|
||||
That is what lets the browser-side webmail (the bulwark chart) call this
|
||||
server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is
|
||||
entirely on the client side: a console error in the browser, and nothing
|
||||
whatsoever in this server's logs.
|
||||
|
||||
The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors`
|
||||
field in the CRDs this repository pins — so this is not a missing feature of
|
||||
the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly:
|
||||
|
||||
- the chart has no CORS values to render it from (the origin and the method
|
||||
list are the operator's, and the chart sets no CORS annotations in
|
||||
Ingress mode either),
|
||||
- that filter's support level is "Extended", so an implementation may
|
||||
reject the route — and rejection is whole-route, which would take the
|
||||
WebUI and JMAP down together — or, worse, accept it and not honour it,
|
||||
- and the part a plain header filter COULD do is the part that does not
|
||||
help here: the browser's PREFLIGHT has to be answered by the gateway
|
||||
rather than by Stalwart, and because the allowed headers include
|
||||
Authorization, EVERY request from the webmail is preflighted.
|
||||
|
||||
So: configure CORS AT THE GATEWAY, with that implementation's own support,
|
||||
BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl
|
||||
sends no preflight, so it will pass while the webmail fails.
|
||||
|
||||
ingress.annotations is inert in this mode in general: the Gateway API has no
|
||||
annotation channel for controller behaviour.
|
||||
|
||||
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname
|
||||
the WebUI sends administrators to lives IN THE DATABASE, not in any value
|
||||
here. Upstream moves administration to https://<the configured server
|
||||
hostname>/admin once setup is complete, so this route only works while an
|
||||
HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3).
|
||||
{{- else }}
|
||||
|
||||
NO INGRESS
|
||||
ingress.enabled is false, so there is no Ingress and no WebUI certificate.
|
||||
NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so there is no Ingress,
|
||||
no HTTPRoute and no WebUI certificate.
|
||||
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
|
||||
dnsNames, and without it the certificate would carry no name at all.
|
||||
{{- end }}
|
||||
@@ -363,6 +476,8 @@ NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
|
||||
that never becomes Ready reads as a broken application rather than as a
|
||||
policy. The port carries no nodePort, so this is open inside the cluster, not
|
||||
from outside. Narrow it with networkPolicy.probeSources, and then add the
|
||||
ingress controller's namespace to networkPolicy.allowedNamespaces or the
|
||||
Ingress goes dark.
|
||||
{{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
|
||||
traffic arrives from the Gateway's data-plane pods, which usually live in a
|
||||
namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the
|
||||
{{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,165 @@
|
||||
{{/*
|
||||
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 }}
|
||||
@@ -390,7 +390,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
|
||||
"description": "Gates the Ingress template only. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1) - and the HTTPRoute reads it as its hostname as well. clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -405,16 +405,34 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"nodeGroup": {
|
||||
"description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
|
||||
"description": "Which node group the routing object - Ingress or HTTPRoute - points at, through that group's own http Service. 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. Must be a key of nodes: and must expose http; both are checked in both templates. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. INERT IN GATEWAY MODE, and for this chart that is the consequential one: this installation's live Ingress carries its CORS configuration here (enable-cors plus allow-origin/methods/headers for the bulwark webmail). See values.yaml, httpRoute block - the Gateway API does have a CORS filter, and the chart still does not render it.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; the HTTP hostname's certificate sits on the Gateway's listener and belongs to the platform. ONE route, not one per node group - it targets ingress.nodeGroup's http Service, with the same two guards the Ingress has. THE MAIL CERTIFICATE STAYS in this mode: it covers STARTTLS on 25 and implicit TLS on 465/993, which pass through no HTTP proxy, so ingress.domain stays required and the Certificate must not be removed as part of a migration. SMTP, IMAP and ManageSieve are not routed here any more than by the Ingress. NOT COVERED IN THIS MODE: the CORS configuration this installation relies on for the bulwark webmail. A CORS filter DOES exist in the pinned gateway-api CRDs (v1.6.3, GEP-1767) but is 'Support: Extended', implementations vary (Cilium accepts it and forwards the preflight upstream anyway), the chart has no CORS values to render it from, and a bare responseHeaderModifier cannot answer a preflight - which is every request here, since the allowed headers include Authorization. Configure CORS at the Gateway and test from a browser, not with curl. See values.yaml, httpRoute block.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/parentRef" }
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the DATABASE, not in any chart value: after bootstrap the WebUI is reached at https://<configured server hostname>/admin, so a second hostname can be reachable without the application agreeing that it is its name.",
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
|
||||
"type": ["object", "null"],
|
||||
@@ -455,6 +473,11 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"exposedPorts": {
|
||||
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
|
||||
"type": "array",
|
||||
|
||||
@@ -625,8 +625,137 @@ ingress:
|
||||
nodeGroup: full
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets.
|
||||
#
|
||||
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
|
||||
# controller behaviour. For THIS chart that is the most consequential line in
|
||||
# the whole block: read the httpRoute section below before switching modes,
|
||||
# because what lives in here on the live installation is the CORS
|
||||
# configuration the webmail depends on.
|
||||
annotations: {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutually exclusive with ingress.enabled - the render aborts when both are
|
||||
# true, and the message names both value paths. The chart renders ONLY an
|
||||
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
|
||||
# hostname, and no implementation-specific policy object.
|
||||
#
|
||||
# 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 the ingress.nodeGroup group,
|
||||
# through that group's own http Service, and aborts the render if that group
|
||||
# does not expose "http" - the same two guards the Ingress has, for the same
|
||||
# reason: the WebUI's setup and management screens are not safe to spread over
|
||||
# a group that may not run the management tasks.
|
||||
#
|
||||
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
|
||||
# anything does. certificate.enabled renders a Certificate for 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 "cleaned up" as part of the migration.
|
||||
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
|
||||
# certificate: clients see a name mismatch while every Kubernetes object in the
|
||||
# release stays green.
|
||||
#
|
||||
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
|
||||
# routed by the Ingress. They keep reaching the pods through the per-port
|
||||
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
|
||||
# this chart renders neither, and doing so would be a separate decision.
|
||||
#
|
||||
# WHAT THIS MODE COSTS - 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):
|
||||
#
|
||||
# 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 what allows the browser-side webmail to call this server's
|
||||
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
|
||||
# error in the browser and NO server-side symptom whatsoever.
|
||||
#
|
||||
# AND "no counterpart" would be the wrong thing to claim here. Measured against
|
||||
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
|
||||
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
|
||||
# inference:
|
||||
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
|
||||
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
|
||||
# Its documented support level, verbatim from that CRD: "Support:
|
||||
# Extended".
|
||||
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
|
||||
# Extended", not Core.
|
||||
#
|
||||
# There IS a counterpart, then, and this chart still does not render it. Three
|
||||
# reasons, in order:
|
||||
#
|
||||
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
|
||||
# - a specific origin, a specific method list. Rendering a filter means
|
||||
# inventing a values surface in a sweep PR, and one that would exist in
|
||||
# Gateway mode only, since the 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. An implementation that does not support
|
||||
# an Extended value reports it in the route's status, and a rejected route
|
||||
# is rejected WHOLE - which here would take the WebUI and JMAP down
|
||||
# together (the reasoning #119 arrived at for statusCode). The observed
|
||||
# failure is quieter still: Cilium accepts filters.type: CORS and
|
||||
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
|
||||
# i.e. Accepted: True with the behaviour absent. A chart cannot know 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. 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 a
|
||||
# path carrying it MUST NOT pass the preflight upstream. A bare header
|
||||
# modifier cannot do that. 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.
|
||||
#
|
||||
# So configure CORS AT THE GATEWAY, using that implementation's own CORS
|
||||
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
|
||||
# curl: curl sends no preflight, so it passes while a browser fails.
|
||||
#
|
||||
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
|
||||
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
|
||||
# without them fails at install, so this is a value and not a guess.
|
||||
httpRoute:
|
||||
enabled: false
|
||||
|
||||
# The Gateway(s) the route attaches to - a platform object, usually in
|
||||
# another namespace, whose listener must allow this namespace in
|
||||
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
|
||||
# optionally namespace, sectionName, port, group, kind), passed through
|
||||
# unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
|
||||
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
|
||||
# Set explicitly only to publish under a different or an additional name -
|
||||
# which is how a migration from the Ingress runs: the HTTPRoute takes a
|
||||
# second hostname while the Ingress keeps ingress.domain.
|
||||
#
|
||||
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
|
||||
# bootstrap the WebUI is reached at https://<the configured server
|
||||
# hostname>/admin, and no value of this chart changes that - so a second
|
||||
# hostname is reachable without the application agreeing that it is its name.
|
||||
hostnames: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardening — chart-wide, applied to every node group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user