Files
helm-charts/stalwart/templates/stalwart.sfs.yaml
T
jklein 26be67f4cd
validate-charts.yml / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Blocked by required conditions
validate-charts.yml / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Blocked by required conditions
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 6s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 22s
Validate Helm Charts / Install-test (pull_request) Successful in 2m22s
Install-test / Install-test stalwart (pull_request) Successful in 2m22s
feat(stalwart): new chart for the Stalwart mail server
Cluster-capable from the start, with roles rather than symmetry: one
StatefulSet per entry of the "nodes" map, the key passed in STALWART_ROLE.

Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP, so the chart's scope is deliberately narrow: it renders
config.json (exactly the DataStore object), the workloads, the Services, the
NetworkPolicies, the Ingress and a cert-manager Certificate. Listeners, blob
store, in-memory store, TLS object and cluster roles are operator work in the
WebUI, which is why NOTES.txt carries an eight-step first install.

config.json stays secret-free: authSecret uses the EnvironmentVariable variant
(verified against the serde definition of PostgreSqlStore at v0.16.24), so the
password arrives through secretKeyRef and checksum/config stays meaningful.
The username cannot follow it - authUsername is a plain String with no secret
form - so it is a public value and says so.

Services are per PORT, not per group: a NodePort is cluster-unique, so "full"
and "mx" can only accept SMTP behind one Service, selected through a per-port
pod label. The http port gets a per-group ClusterIP Service instead, so the
Ingress reaches exactly ingress.nodeGroup.

Four traps, documented rather than explained away:

- The role lookup FAILS OPEN. A node whose ClusterRole is missing from the
  database enables every listener and every task while staying Running with a
  green probe, and the log line arrives after the listeners are bound. Hence
  mx at replicas 0, per-port Services, and a NetworkPolicy that is a ceiling
  independent of what was bound.
- NET_BIND_SERVICE is a documented deviation needed to EXEC, not to bind. The
  image sets setcap cap_net_bind_service=+ep, which makes the kernel fail
  execve when the bounding set is empty. The values comment says both why it
  is there and that it is never used, because the second half is what stops
  the next hardening pass from removing it.
- externalTrafficPolicy must be Cluster - no pod runs on a control-plane node
  while the HA proxy delivers to them, so Local would drop every packet - and
  the source address that costs is restored only by the HA proxy's PROXY v2
  header.
- /healthz is rate-limited to 100 requests/60s per source IP, which is a node
  IP for kubelet probes, so all pods on a node share one bucket. The probe
  intervals are calculated (6 req/min/pod, ~16 pods per node) and the sum is a
  comment in values.yaml.

A first install needs bootstrap.enabled: true: the setup wizard appears only
when config.json is ABSENT, and this chart always renders one, so recovery
mode is upstream's documented path for a templated config.json.

Second chart in the repo with a dependency - the Valkey project's own chart,
off by default behind condition:, because Stalwart's cluster coordinator rides
on the in-memory store connection. The render aborts when more than one pod
runs with no in-memory store address anywhere.

Also wired up: publish-stalwart.yml through the shared block in
workflow-util/pipelines, the change-app-version.yml choice list, a Renovate
customManagers block anchored on the v prefix (which excludes the moving
latest / v0.16 / -alpine tags), CI fixtures, and the README and CLAUDE.md
lines that claimed ytdlp-web-player was the only chart with a dependency.

Verified locally: ci/helm-dependencies.sh, helm lint --strict (0 failed),
helm package, helm template against chart defaults and against
ci/stalwart/test-values.yaml, the rendered config.json parsed as JSON with the
field types the serde definition requires, ci/run-quantity-render-test.sh
(0 failed, both nodes.*.resources paths exercised), NOTES.txt rendered through
a scratch ConfigMap, and the null-safety and fail paths (securityContext=null
keeps NET_BIND_SERVICE; a cluster without an in-memory store, an unknown port
name, a group without http and an unknown ingress.nodeGroup all abort with a
named message; the schema rejects timeout, a string timeoutMs and a list-shaped
nodes). NO runtime verification was possible - there is no k3d or Docker daemon
in this workspace.

Part of #65
2026-10-02 19:13:49 +00:00

267 lines
13 KiB
YAML

{{- /*
One StatefulSet per node group. The group key is the cluster-role name and is
passed in STALWART_ROLE.
A StatefulSet and not a Deployment, although this chart mounts no PVC: the
node-id lease Stalwart acquires for clustering is keyed on the hostname, and
only a StatefulSet gives a pod a stable one. STALWART_HOSTNAME is fed from the
pod name for exactly that reason.
NO volumeClaimTemplates. All state is external - messages and metadata in
Postgres, blobs in S3, the in-memory store in Valkey - so both image volumes
are emptyDirs. That is also why there is no fsGroup in the pod context.
*/ -}}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- /* Evaluated here, in a template that is ALWAYS rendered, because this is
where the "cluster without an in-memory store" render abort lives. A
guard that only ran from NOTES.txt would never fire under helm template
or helm lint - NOTES.txt is not rendered by either - and would therefore
pass every check in CI while being broken (guideline 12.2). The value is
emitted as a comment below, which is also the address an operator has to
enter in the WebUI. */ -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
---
{{ if $valkeyHost -}}
# In-memory store this release expects: {{ $valkeyHost }}:{{ $root.Values.externalServices.valkey.port }}
# NOT configured by this chart - it is an InMemoryStore object in the database,
# and this is the address to enter in the WebUI. The cluster coordinator's
# Default variant reuses the same connection.
{{ end -}}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $name }}-sfs
labels:
app: {{ $name }}-sfs
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
stalwart.jk-dev.de/node-group: {{ $group }}
spec:
replicas: {{ include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas) }}
serviceName: {{ $name }}-headless
selector:
matchLabels:
app: {{ $name }}-sfs
template:
metadata:
name: {{ $name }}-sfs
labels:
app: {{ $name }}-sfs
# Scopes every per-port Service to THIS release, so a second stalwart
# release in the same namespace does not get its pods selected.
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
stalwart.jk-dev.de/node-group: {{ $group }}
{{- /* One label per port this group may be reached on. The per-port
Services select on these: NodePorts are cluster-globally
unique, so "full" and "mx" can only accept SMTP behind the SAME
Service object, and a label is what lets one selector cover
both groups while still excluding a group that does not list
the port. */}}
{{- range $spec.exposedPorts }}
{{ include "stalwart.portLabel" . }}: "true"
{{- end }}
annotations:
# Hash of the rendered ConfigMap: a changed config.json changes the pod
# template and therefore restarts the pods. Without it the change would
# be invisible to the running container - the file is mounted by
# subPath, which the kubelet never refreshes, and Stalwart reads it only
# at start.
checksum/config: {{ include (print $root.Template.BasePath "/stalwart.configmap.yaml") $root | sha256sum }}
# Hash of the rendered OnePasswordItems - the op:// PATHS, not their
# contents. Repointing secrets.* at a different vault item therefore
# takes effect instead of leaving the old credentials in the running
# pod. Named separately from checksum/config on purpose: that one
# promises "the config changed", this one only "the config SOURCE
# changed". A change inside an item is not covered and cannot be -
# Helm never sees a secret's content.
checksum/secret-ref: {{ include (print $root.Template.BasePath "/stalwart.secret.yaml") $root | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "stalwart.podSecurityContext" $root.Values.securityContext | nindent 8 }}
initContainers:
# config.json is copied out of the ConfigMap into the emptyDir rather
# than mounted from it, because STALWART WRITES THIS FILE ITSELF: on
# completing its setup wizard it "writes config.json ... and restarts
# into normal operation", and a ConfigMap mount is read-only. Copying
# keeps /etc/stalwart writable AND keeps checksum/config honest, which
# a bare emptyDir would not.
#
# The copy direction matters: the chart's version wins on every pod
# start. If an operator has let the server rewrite the file, that
# rewrite is discarded on the next restart - which is the intended
# behaviour here (the chart owns the data store setting) but is worth
# knowing before editing it through the WebUI.
- name: copy-config
image: "{{ $root.Values.initImage.repository }}:{{ $root.Values.initImage.tag }}"
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- cp /chart-config/config.json /etc/stalwart/config.json
volumeMounts:
- name: config
mountPath: /chart-config
readOnly: true
- name: etc-stalwart
mountPath: /etc/stalwart
resources:
{{- include "stalwart.resources" (dict "limitFactor" 3 "requests" (dict "cpu" "10m" "memory" "16Mi" "ephemeral-storage" "8Mi")) | nindent 12 }}
securityContext:
{{- include "stalwart.containerSecurityContext" $root.Values.securityContext | nindent 12 }}
containers:
- name: {{ $name }}-sfs
image: "docker.io/stalwartlabs/stalwart:{{ $root.Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
# The cluster role. A ClusterRole object of exactly this name has
# to exist in the database - if it does not, the node silently
# becomes a FULL node (every listener AND every task) while staying
# Running with a green readiness probe. See values.yaml.
- name: STALWART_ROLE
value: {{ $group | quote }}
# The node identity for the cluster node-id lease. The pod name is
# stable because this is a StatefulSet; the OS hostname inside the
# pod would be too, but reading it from the API is what makes the
# value survive a hostname the image cannot change.
- name: STALWART_HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- if $root.Values.bootstrap.enabled }}
# RECOVERY MODE IS ON: management HTTP on 8080 only, every mail
# port closed. Required for a first install because this chart
# always renders config.json and the bootstrap wizard only appears
# when that file is ABSENT. Turn it off and upgrade again once the
# database is provisioned.
- name: STALWART_RECOVERY_MODE
value: "1"
- name: STALWART_RECOVERY_MODE_LOG_LEVEL
value: {{ $root.Values.bootstrap.logLevel | quote }}
{{- end }}
{{- if $root.Values.secrets.dataStoreSecretRef }}
# Referenced BY NAME from config.json as
# "authSecret": {"@type":"EnvironmentVariable","variableName":"..."}
# which is what keeps the password out of the ConfigMap. An empty
# value counts as "not found" upstream and fails the store open.
- name: {{ include "stalwart.env.dbPassword" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-datastore-secret
key: password
{{- end }}
{{- if $root.Values.secrets.recoveryAdminSecretRef }}
- name: STALWART_RECOVERY_ADMIN
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-recovery-admin-secret
key: recovery-admin
{{- end }}
{{- if $root.Values.secrets.s3SecretRef }}
# Not read by the chart and not by config.json: these exist so the
# BlobStore object in the DATABASE can reference them instead of
# storing the credentials there.
- name: {{ include "stalwart.env.s3AccessKey" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-s3-secret
key: access-key
- name: {{ include "stalwart.env.s3SecretKey" $root }}
valueFrom:
secretKeyRef:
name: {{ include "stalwart.fullname" $root }}-s3-secret
key: secret-key
{{- end }}
{{- range $value := $spec.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" or "true"
# renders unquoted as a YAML number/bool and the API server
# rejects the whole manifest.
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
- name: {{ $portName }}
# containerPort, NOT nodePort. This is the number the application
# binds and the number the NetworkListener object in the database
# has to carry; the nodePort only appears in the Service.
containerPort: {{ int $port.containerPort }}
protocol: {{ $port.protocol | default "TCP" }}
{{- end }}
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if".
livenessProbe:
{{- include "stalwart.livenessProbe" $root.Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "stalwart.readinessProbe" $root.Values.readinessProbe | nindent 12 }}
startupProbe:
{{- include "stalwart.startupProbe" $root.Values.startupProbe | nindent 12 }}
resources:
{{- include "stalwart.effectiveResources" (dict "group" $group "given" $spec.resources "limitFactor" $root.Values.limitFactor) | nindent 12 }}
securityContext:
{{- include "stalwart.containerSecurityContext" $root.Values.securityContext | nindent 12 }}
volumeMounts:
- name: etc-stalwart
mountPath: /etc/stalwart
- name: var-lib-stalwart
mountPath: /var/lib/stalwart
- name: tmp
mountPath: /tmp
{{- if $root.Values.certificate.enabled }}
- name: mail-tls
mountPath: {{ $root.Values.certificate.mountPath }}
readOnly: true
{{- end }}
volumes:
- name: config
configMap:
name: {{ include "stalwart.fullname" $root }}-configmap
# Writable because Stalwart rewrites config.json itself after its setup
# wizard; the init container seeds it from the ConfigMap.
- name: etc-stalwart
emptyDir: {}
# The image's data volume. An emptyDir because the data store, the blob
# store and the in-memory store are all external.
#
# OPEN MEASUREMENT: whether anything under this path has to survive a
# restart once all three stores are external. If it does, this needs a
# PVC per group. Not verified at runtime.
- name: var-lib-stalwart
emptyDir: {}
- name: tmp
emptyDir: {}
{{- if $root.Values.certificate.enabled }}
- name: mail-tls
secret:
secretName: tls-{{ include "stalwart.fullname" $root }}-mail
# The pods do not block on the certificate: a Secret that
# cert-manager has not written yet would otherwise hold every pod
# in ContainerCreating until the challenge completes. Stalwart
# falls back to a self-signed certificate on the mail ports in the
# meantime, which is the better of the two failures.
optional: true
{{- end }}
restartPolicy: Always
{{- end }}