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
484 lines
28 KiB
Plaintext
484 lines
28 KiB
Plaintext
{{- /*
|
|
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 $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
|
|
Since v0.16 Stalwart keeps its whole configuration inside the data store and
|
|
manages it over JMAP. The only file on disk is config.json, which names the
|
|
data store and nothing else. So this release has given you the workloads, the
|
|
Services, the NetworkPolicies, the Ingress and a certificate — but no
|
|
domains, no listeners, no blob store, no in-memory store and no cluster
|
|
roles. Those are the steps below, and without them this is not yet a mail
|
|
server.
|
|
|
|
NODE GROUPS IN THIS RELEASE
|
|
{{- range $group, $spec := $nodes }}
|
|
{{- $replicas := include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas) }}
|
|
{{ printf "%-10s" $group }} replicas {{ $replicas }}, reachable on: {{ $spec.exposedPorts | join ", " }}
|
|
{{- end }}
|
|
{{- if .Values.scaleDown }}
|
|
|
|
scaleDown: true — every group is at replicas 0 and nothing is running. The
|
|
Services, Ingress, Secrets and the Certificate stay in place, so setting it
|
|
back to false brings the release up without re-issuing the certificate.
|
|
{{- end }}
|
|
{{- if .Values.bootstrap.enabled }}
|
|
|
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
RECOVERY MODE IS ON — THIS RELEASE SERVES NO MAIL
|
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
bootstrap.enabled is true, so every pod runs with STALWART_RECOVERY_MODE=1:
|
|
the management HTTP endpoint on {{ (index $ports "http").containerPort }} is the ONLY thing listening and
|
|
every mail port is closed. The Services and the Ingress look exactly as
|
|
healthy as they do in normal operation, which is why this banner exists.
|
|
|
|
This is correct for a first install (step 1 below). When the database is
|
|
provisioned, set bootstrap.enabled: false and upgrade again.
|
|
{{- end }}
|
|
|
|
THE 1PASSWORD ITEMS MUST CARRY THE FIELDS THIS CHART READS
|
|
One Secret key is created per FIELD of the item, and these templates
|
|
reference the keys by name — an item missing a field leaves the container in
|
|
CreateContainerConfigError. An EMPTY field fails the same way as a missing
|
|
one: Stalwart's secret resolver treats an empty environment variable as "not
|
|
found".
|
|
{{- if .Values.secrets.dataStoreSecretRef }}
|
|
|
|
secrets.dataStoreSecretRef → {{ .Values.secrets.dataStoreSecretRef }}
|
|
field: password (the data store password)
|
|
The USERNAME is not in this item and cannot be: authUsername is a plain
|
|
String on the DataStore object with no secret form. It is
|
|
dataStore.postgres.username = "{{ .Values.dataStore.postgres.username }}" and is rendered into the
|
|
ConfigMap.
|
|
{{- else }}
|
|
|
|
secrets.dataStoreSecretRef is NOT SET, so no data store password reaches the
|
|
pods and config.json references an environment variable that will not exist.
|
|
Stalwart fails to open the store with
|
|
Environment variable '{{ include "stalwart.env.dbPassword" . }}' not found
|
|
Set it unless the database genuinely takes no password.
|
|
{{- end }}
|
|
{{- if .Values.secrets.recoveryAdminSecretRef }}
|
|
|
|
secrets.recoveryAdminSecretRef → {{ .Values.secrets.recoveryAdminSecretRef }}
|
|
field: recovery-admin (the COMPLETE value "admin:<password>")
|
|
One field, not two. The upstream parser splits on the first ":" and drops
|
|
the entry when either half is empty, so a half-filled item would silently
|
|
mean "no recovery admin" instead of failing — and this credential logs in
|
|
regardless of directory state.
|
|
|
|
REMOVE IT WHEN YOU ARE DONE. It is a back door. Once a permanent
|
|
administrator exists in the database, set secrets.recoveryAdminSecretRef
|
|
to null and upgrade; the variable then disappears from the pods.
|
|
{{- else }}
|
|
|
|
secrets.recoveryAdminSecretRef is NOT SET. That is the right end state once a
|
|
permanent administrator exists — but during a first install or a recovery
|
|
start it means Stalwart generates a temporary administrator password and
|
|
prints it to STDOUT, i.e. into the pod log, freshly on every restart and
|
|
visible to anyone who can read logs. Catch it with
|
|
kubectl -n {{ .Release.Namespace }} logs sts/{{ $release }}-{{ $ingressGroup | default "<group>" }}-sfs | grep -A3 bootstrap
|
|
{{- end }}
|
|
{{- if .Values.secrets.s3SecretRef }}
|
|
|
|
secrets.s3SecretRef → {{ .Values.secrets.s3SecretRef }}
|
|
fields: access-key, secret-key
|
|
Exposed to the containers as {{ include "stalwart.env.s3AccessKey" . }} and
|
|
{{ include "stalwart.env.s3SecretKey" . }}. The chart does NOT configure the blob
|
|
store; point the BlobStore object in the WebUI at these names:
|
|
accessKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3AccessKey" . }}"}
|
|
secretKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3SecretKey" . }}"}
|
|
{{- end }}
|
|
|
|
CHANGING WHAT IS INSIDE AN ITEM RESTARTS NOTHING. Helm never sees a secret's
|
|
content, so there is nothing to hash. Repointing one of the values above at a
|
|
different vault item DOES roll the pods (checksum/secret-ref covers the
|
|
op:// path). After rotating a password inside an item:
|
|
{{- range $group, $spec := $nodes }}
|
|
kubectl -n {{ $.Release.Namespace }} rollout restart statefulset/{{ $release }}-{{ $group }}-sfs
|
|
{{- end }}
|
|
|
|
FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
|
|
1. bootstrap.enabled: true, nodes.mx.replicas: 0.
|
|
The setup wizard everyone reaches for appears only when config.json is
|
|
ABSENT, and this chart always renders one — so the wizard never runs here.
|
|
Recovery mode is the documented path for a templated config.json: it
|
|
opens the management endpoint and keeps the mail ports shut. With a second
|
|
group running, the wizard would be unusable anyway: each request would
|
|
land on a random pod.
|
|
|
|
2. Open the management UI and create the permanent administrator.
|
|
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $release }}-{{ $ingressGroup | default "<group>" }}-http-service {{ (index $ports "http").containerPort }}:{{ (index $ports "http").containerPort }}
|
|
then http://localhost:{{ (index $ports "http").containerPort }}/admin
|
|
|
|
3. In the WebUI, create: the blob store (S3), the in-memory store
|
|
({{ if $valkeyHost }}{{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}{{ else }}no address configured — see externalServices.valkey{{ end }}),
|
|
the coordinator (Default — it reuses the in-memory store connection), the
|
|
search store (Default — it uses the data store, so no extra service is
|
|
needed), the TLS object pointing at the certificate from step 5, the
|
|
NetworkListener objects, and one ClusterRole per node group below.
|
|
|
|
THE LISTENER PORTS MUST MATCH THIS RELEASE EXACTLY. The chart cannot
|
|
create listener objects, so these numbers are a second copy of a decision
|
|
made in the WebUI, and a mismatch is silent — the Service points at a port
|
|
nothing listens on, every pod stays Ready, nothing reports it:
|
|
{{- range $portName, $port := $ports }}
|
|
{{ printf "%-12s" $portName }} bind {{ printf "%-5s" (printf "%v" $port.containerPort) }}{{ if $port.public }} (published on node port {{ $port.nodePort }}){{ else }} (not published){{ end }}
|
|
{{- end }}
|
|
|
|
AND ONE ClusterRole PER GROUP, NAMED EXACTLY:
|
|
{{- range $group, $spec := $nodes }}
|
|
{{ $group }}
|
|
{{- end }}
|
|
A node whose role name is not in the database does not fail. It becomes a
|
|
FULL node — every listener and every task — while staying Running with a
|
|
green readiness probe. The only trace is one log line,
|
|
'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 {{ 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 }}
|
|
certificate: {{ .Values.certificate.mountPath }}/tls.crt
|
|
privateKey: {{ .Values.certificate.mountPath }}/tls.key
|
|
cert-manager writes these into tls-{{ $release }}-mail. Watch it with
|
|
kubectl -n {{ .Release.Namespace }} get certificate {{ $release }}-mail-certificate
|
|
Until it is issued the mail ports serve a self-signed certificate: the
|
|
volume is mounted optional so that a pending challenge does not hold every
|
|
pod in ContainerCreating.
|
|
{{- else }}
|
|
certificate.enabled is false, so this chart issues no mail certificate.
|
|
The Ingress certificate does NOT cover the mail ports — the HA proxy
|
|
terminates no TLS, so Stalwart has to present one itself for STARTTLS on
|
|
25 and implicit TLS on 465/993. Provide it another way or clients will see
|
|
a name mismatch.
|
|
{{- end }}
|
|
|
|
6. Set send-proxy-v2 on the HA proxy. ALL OR NOTHING WITH STEP 3.
|
|
externalTrafficPolicy is {{ .Values.service.externalTrafficPolicy }}, which it has to be — no Stalwart
|
|
pod runs on a control-plane node, and the HA proxy delivers to the
|
|
control-plane nodes, so Local would drop every packet. The cost is that
|
|
kube-proxy masquerades the source address, and on a mail server a lost
|
|
source address switches off SPF, takes DMARC with it, makes every
|
|
blocklist report "clean", collapses per-IP rate limits onto one address,
|
|
removes greylisting's key, and makes the auto-ban lock out every sender at
|
|
once. The Received: chain becomes worthless — the one item there that
|
|
destroys evidence retroactively rather than just failing.
|
|
|
|
The PROXY v2 header restores it, for port 25 as well, because the HA proxy
|
|
opens its own connection to the node. But a trusted network makes the
|
|
header MANDATORY: without it a connection from there hangs until timeout,
|
|
and with it from an untrusted network Stalwart reads "PROXY TCP4 ..." as
|
|
an SMTP command. Test on a spare port with its own frontend, and move 25
|
|
only once a Received: line shows the real sender address.
|
|
|
|
7. Restart the pods — "a newly added listener binds only on the next start" —
|
|
and set bootstrap.enabled: false in the same round.
|
|
|
|
8. Raise nodes.mx.replicas only after the "mx" ClusterRole exists (step 3),
|
|
and publish DNS (below).
|
|
|
|
DNS A MAIL SERVER CANNOT RUN WITHOUT
|
|
None of this is rendered by the chart; all of it is required.
|
|
MX {{ .Values.ingress.domain | default "<your mail domain>" }}. → this deployment's public name
|
|
SPF TXT "v=spf1 ..." naming the HA proxy's address as a sender
|
|
DKIM TXT at <selector>._domainkey — the key is generated IN Stalwart
|
|
(Domains → DKIM), so this record is published after step 3
|
|
DMARC TXT at _dmarc — start at p=none with rua= and tighten only once
|
|
the reports are clean
|
|
REVERSE DNS PTR for the HA PROXY's public address, matching its forward
|
|
name. Not the node's and not the pod's: the HA proxy is what
|
|
other mail servers see connecting. Many receivers reject mail
|
|
outright when it is missing, and nothing in Kubernetes can set
|
|
it — it belongs to whoever owns the IP range.
|
|
|
|
WHAT A GREEN POD PROVES HERE, AND WHAT IT DOES NOT
|
|
Liveness asks /healthz/live, readiness and startup ask /healthz/ready.
|
|
Read off the source at v0.16.24: /healthz/ready returns 200 as soon as a data
|
|
store HANDLE exists — it does not query the database. So Ready does not mean
|
|
the database is reachable, and it does not catch a pod that fell back to an
|
|
ephemeral in-memory store because config.json was missing. A green pod here
|
|
means the process is answering HTTP.
|
|
|
|
The probe intervals are calculated, not chosen: Stalwart rate-limits
|
|
anonymous /healthz requests to 100 per 60s PER SOURCE IP, probes arrive from
|
|
the node IP, and so every pod on a node shares one bucket. The defaults cost
|
|
6 requests/min/pod and leave room for about 16 pods per node. Tightening them
|
|
produces 429s, and a 429 on liveness is a restart loop.
|
|
{{- $anySpread := false }}
|
|
{{- range $group, $spec := $nodes }}
|
|
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
|
|
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
|
|
{{- if and $s.spreadAcrossNodes (ge $r 2) }}{{ $anySpread = true }}{{ end }}
|
|
{{- end }}
|
|
|
|
DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
|
|
Both objects are conditional, so this is the list rather than something to
|
|
infer from `kubectl get pdb` and an `-o yaml`.
|
|
{{- range $group, $spec := $nodes }}
|
|
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
|
|
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
|
|
|
|
{{ $group }} — {{ $r }} effective replica(s)
|
|
{{- if and $s.spreadAcrossNodes (ge $r 2) }}
|
|
SPREAD ENFORCED. maxSkew 1 on kubernetes.io/hostname, whenUnsatisfiable
|
|
DoNotSchedule, nodeAffinityPolicy Ignore, selecting this group's
|
|
own pods only — a pod of "{{ $group }}" sharing a node with a pod of
|
|
another group stays fine. This is a PROMISE, so the price is a Pending pod
|
|
rather than a co-located one: if fewer nodes are available than
|
|
this group has replicas, the surplus replica does not schedule and
|
|
there is NO scheduling event to read. Check with
|
|
kubectl -n {{ $.Release.Namespace }} get pod -l stalwart.jk-dev.de/node-group={{ $group }} -o wide
|
|
{{- else if $s.spreadAcrossNodes }}
|
|
SPREAD NOT RENDERED, because this group has {{ $r }} effective replica(s) and the
|
|
rule needs two. {{ if eq $r 0 }}With no pod at all there is nothing to
|
|
distribute{{ else }}With one pod every placement satisfies it, so the object
|
|
would describe nothing{{ end }}. Raise nodes.{{ $group }}.replicas to 2 to get it —
|
|
and note that doing so changes the POD TEMPLATE, so {{ if eq $r 0 }}the group
|
|
starts with the constraint already in place{{ else }}the pod running now is
|
|
replaced in the same rollout round, not merely joined{{ end }}.
|
|
{{- else }}
|
|
SPREAD OFF — nodes.{{ $group }}.spreadAcrossNodes is false. Nothing keeps two
|
|
pods of this group off the same node, so its replicas are
|
|
redundancy on paper only unless your own
|
|
nodes.{{ $group }}.topologySpreadConstraints or affinity does the job.
|
|
{{- end }}
|
|
{{- if and $s.podDisruptionBudget.enabled (ge $r 2) }}
|
|
PDB {{ $release }}-{{ $group }}-pdb, minAvailable {{ $s.podDisruptionBudget.minAvailable | int64 }}. A node drain cannot take
|
|
all {{ $r }} replicas of this group at once.
|
|
{{- else if $s.podDisruptionBudget.enabled }}
|
|
PDB NONE, because the effective replica count is {{ $r }} and a budget needs
|
|
two. Deliberate: {{ if eq $r 0 }}with no pod at all the object would only
|
|
describe a promise about nothing{{ else }}minAvailable 1 with a single replica
|
|
forbids every voluntary eviction, so `kubectl drain` would block
|
|
forever on a pod nothing may move — and the error would name the
|
|
PDB rather than the replica count{{ end }}.
|
|
{{- else }}
|
|
PDB NONE — nodes.{{ $group }}.podDisruptionBudget.enabled is false. A node
|
|
drain may take every replica of this group at once.
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- if $anySpread }}
|
|
|
|
ONE VERSION CAVEAT, AND IT FAILS SILENTLY: nodeAffinityPolicy is a beta field
|
|
— on by default from Kubernetes 1.26, GA with the feature gate
|
|
NodeInclusionPolicyInPodTopologySpread in 1.33. An older API server, or one
|
|
with the gate off, PRUNES IT WITHOUT A WORD, and then a nodeSelector or
|
|
affinity that narrows this group onto a single node no longer produces a
|
|
Pending pod but two CO-LOCATED ones, with the constraint reported as
|
|
satisfied. Compare the live object against what was rendered:
|
|
kubectl -n {{ .Release.Namespace }} get sts -l stalwart.jk-dev.de/release={{ $release }} \
|
|
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.topologySpreadConstraints[*].nodeAffinityPolicy}{"\n"}{end}'
|
|
An empty column where "Ignore" should be is the whole symptom.
|
|
|
|
AND A CEILING THAT IS A RULE, NOT A NUMBER: this chart leaves
|
|
nodeTaintsPolicy at its API default of Ignore, so every node in the cluster
|
|
counts as a domain — control-plane nodes included, where no Stalwart pod ever
|
|
runs. They hold the minimum at 0 permanently, so with maxSkew 1 a group can
|
|
never place more replicas than there are SCHEDULABLE WORKERS. It moves with
|
|
the cluster: add a worker and it rises, remove one and it falls. Past that
|
|
point a group needs spreadAcrossNodes: false or an own constraint with
|
|
ScheduleAnyway. This ceiling comes from nodeTaintsPolicy and existed before
|
|
nodeAffinityPolicy was set — the two fields are not a package.
|
|
{{- end }}
|
|
{{- if .Values.ingress.enabled }}
|
|
|
|
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
|
|
|
|
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE. Upstream moves
|
|
administration to https://<hostname>/admin once setup is complete, so this
|
|
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 }}
|
|
|
|
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 }}
|
|
{{- if .Values.valkeySubchart.enabled }}
|
|
|
|
THE VALKEY SUBCHART IS ENABLED
|
|
Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }} — this repo's own valkey chart,
|
|
pulled from the published index. Enter that address as the in-memory store in
|
|
step 3; the coordinator's Default variant reuses the same connection.
|
|
|
|
ITS 1PASSWORD ITEM IS A SEPARATE ONE, AND ITS FIELD IS NOT A PASSWORD:
|
|
valkey.secrets.authSecretRef points at
|
|
{{ .Values.valkey.secrets.authSecretRef | default "<NOT SET - the subchart's own required() will abort the render>" }}
|
|
and that item carries ONE field, "auth-conf", whose value is the complete
|
|
configuration line
|
|
|
|
requirepass <password>
|
|
|
|
not a bare password. The valkey chart mounts the file and includes it, and
|
|
an init container refuses to start the pod unless the line is really there —
|
|
because the alternative failure is an open server that looks healthy.
|
|
|
|
IT IS CONFIGURED AS A CACHE, NOT A QUEUE
|
|
persistence.enabled false + maxmemoryPolicy allkeys-lru. Everything Stalwart
|
|
keeps here — rate-limiter counters, auth tokens, sessions, auto-ban entries,
|
|
the coordination pub/sub — is reconstructible after a restart; nothing in it
|
|
is the system of record.
|
|
|
|
The cost of allkeys-lru, so it is not a surprise: under memory pressure keys
|
|
are discarded, so the rate limiters and the auto-ban FAIL OPEN. Size memory
|
|
for the working set rather than switching to noeviction — with noeviction
|
|
Stalwart's writes to the store start erroring, which takes out
|
|
authentication instead of just the limits.
|
|
|
|
scaleDown and checksum/config stop at the subchart boundary, so scaling this
|
|
release to zero does NOT stop the Valkey, and a new valkey chart release
|
|
reaches you only when stalwart itself is republished.
|
|
{{- end }}
|
|
{{- if not .Values.networkPolicy.enabled }}
|
|
|
|
NETWORKPOLICY IS OFF
|
|
networkPolicy.enabled is false, so nothing limits reachability except the
|
|
Services. That removes the second line of defence against the fail-open in
|
|
step 3: a group that accidentally became a full node is then reachable on
|
|
every port it bound.
|
|
{{- else if not .Values.networkPolicy.probeSources }}
|
|
|
|
NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
|
|
networkPolicy.probeSources is empty, so the rule for port {{ (index $ports "http").containerPort }} renders with
|
|
no "from" selector. Deliberate: the kubelet probes from the worker node's IP,
|
|
which this chart cannot know, and a wrong guess blocks every probe — a pod
|
|
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
|
|
{{ 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 }}
|