feat(stalwart): new chart for the Stalwart mail server
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

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
This commit is contained in:
jklein committed 2026-10-02 19:13:49 +00:00
1 parent 9ae7eef99f
commit 26be67f4cd
19 files changed
+3007 -3

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
{{- $release := include "stalwart.fullname" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- $ingressGroup := "" -}}
{{- if .Values.ingress.enabled }}{{- $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 Ingress.
Stalwart sees the ingress controller's 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.
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.
{{- if .Values.ingress.enabled }}
WEB UI AND JMAP
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 }}
NO INGRESS
ingress.enabled is false, so there is no Ingress 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.valkey.enabled }}
THE VALKEY SUBCHART IS ENABLED
Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}, and it is upstream's chart consumed as it
comes. Two of its defaults fall short of this repo's conventions and are NOT
patched by us: auth.enabled is false (unauthenticated access from anything
the CNI permits), and its NetworkPolicy is its own object with its own
semantics, which this chart's networkPolicy block does not reach. See the
valkey section of values.yaml.
scaleDown and checksum/config stop at the subchart boundary, and a new Valkey
release reaches you only when this chart 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
ingress controller's namespace to networkPolicy.allowedNamespaces or the
Ingress goes dark.
{{- end }}
+428
View File
@@ -0,0 +1,428 @@
{{/*
Resource / replica / null-safety helpers.
These are copied from template-chart with "stalwart." as the define prefix,
as in every other chart here: sharing them would mean a library chart, and a
dependency is admissible but expensive (README, guideline 14) - this chart
already carries one and does not need a second. template-chart holds the
reference implementation; the resource-quantity render test in CI exists
because a copy can carry an old bug into a diff nobody reads.
*/}}
{{- define "stalwart.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "stalwart.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "stalwart.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "stalwart.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "stalwart.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
*/}}
{{- define "stalwart.multiplyQuantity" -}}
{{- $quantity := include "stalwart.formatNumber" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%s%s" (include "stalwart.formatNumber" $product) $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render a number as a plain decimal string, never in exponential notation
(issue #157): the null-safe helpers pass values through fromYaml, which parses
every number as float64, and Go's "%v" switches to exponential notation from
about 1e6 upwards - not a valid Kubernetes quantity.
*/}}
{{- define "stalwart.formatNumber" -}}
{{- if kindIs "float64" . -}}
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
{{- else -}}
{{- printf "%v" . -}}
{{- end -}}
{{- end -}}
{{/*
The replica count of one node group.
Input: dict "scaleDown" <bool|nil> "replicas" <int|nil>.
The explicit kindIs "invalid" check is what keeps an explicit 0 - which this
chart relies on, since the "mx" group ships with replicas: 0.
*/}}
{{- define "stalwart.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
null / empty override falls back to the defaults instead of erasing them
(issues #61 and #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
<string>. Output: YAML, consume it with fromYaml.
*/}}
{{- define "stalwart.defaultedDict" -}}
{{- $defaults := .defaults | default dict -}}
{{- $given := .given -}}
{{- $path := .path | default "value" -}}
{{- if kindIs "invalid" $given -}}
{{- $given = dict -}}
{{- end -}}
{{- if not (kindIs "map" $given) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
{{- end -}}
{{- $out := deepCopy $defaults -}}
{{- range $key, $value := $given -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $default := index $defaults $key -}}
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
{{- $_ := set $out $key (fromYaml (include "stalwart.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
{{- else if kindIs "map" $default -}}
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
{{- else -}}
{{- $_ := set $out $key $value -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Pick one sub-block out of a values block that may itself be null.
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
*/}}
{{- define "stalwart.subBlock" -}}
{{- $block := .block -}}
{{- if kindIs "invalid" $block -}}
{{- $block = dict -}}
{{- end -}}
{{- if not (kindIs "map" $block) -}}
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
{{- end -}}
{{- toYaml (dict "value" (index $block .key)) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
The defaults below MUST stay in sync with values.yaml, which stays the
documented, user-facing place for them; this copy is only the fallback for the
case where an override has erased the block.
Two rules these fallbacks exist to honour (README, guideline 12):
- reproduce THIS chart's defaults, not the repo standard. uid/gid 2000 is the
image's own user, and there is deliberately NO fsGroup here because this
chart mounts no PVC.
- carry the probe's CONTEXT, not just its timings. The paths and the port
name are part of these defaults; without them the probes would hit "/" on
a port that may not exist, and liveness would stop being independent of
the data store.
*/}}
{{- define "stalwart.podSecurityContext" -}}
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "pod" "path" "securityContext"))).value -}}
{{- $defaults := dict
"runAsNonRoot" true
"runAsUser" 2000
"runAsGroup" 2000
"seccompProfile" (dict "type" "RuntimeDefault") -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}}
{{- end -}}
{{- define "stalwart.containerSecurityContext" -}}
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "container" "path" "securityContext"))).value -}}
{{/*
NET_BIND_SERVICE is part of the FALLBACK, not only of values.yaml, and that is
the whole point of rule "reproduce THIS chart's defaults, not the repo
standard". The image carries setcap cap_net_bind_service=+ep on its binary, so
with an empty bounding set the kernel fails the execve itself - an erased
securityContext block that fell back to the repo-standard "add: []" would stop
the chart from starting at all, with "operation not permitted" and nothing
pointing at this file.
*/}}
{{- $defaults := dict
"allowPrivilegeEscalation" false
"readOnlyRootFilesystem" true
"runAsNonRoot" true
"capabilities" (dict "drop" (list "ALL") "add" (list "NET_BIND_SERVICE")) -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.container") -}}
{{- end -}}
{{/*
The probe fallbacks carry the calculated intervals, not just the paths. The
arithmetic is in values.yaml: Stalwart rate-limits anonymous /healthz requests
to 100 per 60s PER SOURCE IP, probes come from the node IP, so every pod on a
node shares one bucket. 20/20/10 costs 6 requests/min/pod in both phases
(the startup probe gates the other two, so they never run together) and leaves
room for ~16 pods per node. The obvious 10/10/5 would halve that to ~8.
Tightening these here without redoing that sum produces 429s, and a 429 on
liveness is a restart loop.
*/}}
{{- define "stalwart.livenessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/live" "port" "http")
"periodSeconds" 20
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "livenessProbe") -}}
{{- end -}}
{{- define "stalwart.readinessProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/ready" "port" "http")
"periodSeconds" 20
"timeoutSeconds" 5
"failureThreshold" 3 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "readinessProbe") -}}
{{- end -}}
{{- define "stalwart.startupProbe" -}}
{{- $defaults := dict
"httpGet" (dict "path" "/healthz/ready" "port" "http")
"periodSeconds" 10
"timeoutSeconds" 5
"failureThreshold" 30 -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
{{- end -}}
{{/*
=============================================================================
Chart-specific helpers
=============================================================================
*/}}
{{/*
The common name prefix. One place, because it appears in every object name and
in the Service selectors.
*/}}
{{- define "stalwart.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name -}}
{{- end -}}
{{/*
The validated "ports" map. Rejects a non-mapping and an empty map, and checks
every entry for the one field the chart cannot do without.
Why the number is checked here rather than where it is used: an erased
ports.smtp.number used to render "port:" and "targetPort:" empty into the
Service and an empty "containerPort" into the StatefulSet - rejected by nothing
until the API server saw it (the satisfactory lesson, Gitea #16).
*/}}
{{- define "stalwart.ports" -}}
{{- $ports := .Values.ports -}}
{{- if kindIs "invalid" $ports -}}
{{- fail "ports must be a mapping of port name to { containerPort, nodePort, protocol, public }, and it cannot be erased: every Service, containerPort and NetworkPolicy rule in this chart is rendered from it" -}}
{{- end -}}
{{- if not (kindIs "map" $ports) -}}
{{- fail (printf "ports must be a mapping, got %s" (kindOf $ports)) -}}
{{- end -}}
{{- if eq (len $ports) 0 -}}
{{- fail "ports is empty - the chart would render workloads with no containerPort and no Service at all" -}}
{{- end -}}
{{- range $name, $spec := $ports -}}
{{- if not (kindIs "map" $spec) -}}
{{- fail (printf "ports.%s must be a mapping, got %s (%v)" $name (kindOf $spec) $spec) -}}
{{- end -}}
{{- if kindIs "invalid" $spec.containerPort -}}
{{- fail (printf "ports.%s.containerPort is required - a port without one renders an empty containerPort and an empty Service targetPort. Note containerPort and nodePort are two different numbers here and are not interchangeable: containerPort is what the application binds (and what the NetworkListener object in the database must say), nodePort is what the cluster publishes" $name) -}}
{{- end -}}
{{- if and $spec.public (kindIs "invalid" $spec.nodePort) -}}
{{- fail (printf "ports.%s is marked public but has no nodePort - a NodePort Service without a nodePort would be assigned a random one, which the HA proxy in front of the cluster does not know about" $name) -}}
{{- end -}}
{{- end -}}
{{- toYaml $ports -}}
{{- end -}}
{{/*
The validated "nodes" map, with each group's exposedPorts checked against the
ports map. Input: the root context.
The exposedPorts check is the one that earns its keep: a typo there would
silently drop a port from a group's containerPorts and from its NetworkPolicy,
leaving a Service selecting pods that never bound the port.
*/}}
{{- define "stalwart.nodes" -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $nodes := .Values.nodes -}}
{{- if kindIs "invalid" $nodes -}}
{{- fail "nodes must be a mapping of cluster-role name to node group, and it cannot be erased: it is what every StatefulSet in this chart is rendered from" -}}
{{- end -}}
{{- if not (kindIs "map" $nodes) -}}
{{- fail (printf "nodes must be a mapping of cluster-role name to node group, got %s - note it is a MAP and not a list, so that an override never depends on a position" (kindOf $nodes)) -}}
{{- end -}}
{{- if eq (len $nodes) 0 -}}
{{- fail "nodes is empty - the release would contain Services and an Ingress but no workload" -}}
{{- end -}}
{{- range $group, $spec := $nodes -}}
{{- if not (kindIs "map" $spec) -}}
{{- fail (printf "nodes.%s must be a mapping, got %s (%v)" $group (kindOf $spec) $spec) -}}
{{- end -}}
{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $group) -}}
{{- fail (printf "nodes.%s is not usable as a Kubernetes name segment: a group key becomes part of every object name and of a label value, so it must be lowercase alphanumeric with dashes. It is ALSO the cluster-role name passed in STALWART_ROLE, so renaming it here without renaming the ClusterRole object in the database turns the group into a full node" $group) -}}
{{- end -}}
{{- $exposed := $spec.exposedPorts -}}
{{- if kindIs "invalid" $exposed -}}
{{- fail (printf "nodes.%s.exposedPorts is required - a group with no ports is unreachable, and because this list is also the NetworkPolicy's ingress rule, an erased one is indistinguishable from a deliberate lockout" $group) -}}
{{- end -}}
{{- if not (kindIs "slice" $exposed) -}}
{{- fail (printf "nodes.%s.exposedPorts must be a list of port names, got %s (%v)" $group (kindOf $exposed) $exposed) -}}
{{- end -}}
{{- range $exposed -}}
{{- if not (hasKey $ports .) -}}
{{- fail (printf "nodes.%s.exposedPorts names %q, which is not a key of ports: - known ports are %s. Left unchecked this would drop the port from the group's containerPorts and from its NetworkPolicy while the Service still selected its pods" $group . (keys $ports | sortAlpha | join ", ")) -}}
{{- end -}}
{{- end -}}
{{- if not (has "http" $exposed) -}}
{{- fail (printf "nodes.%s.exposedPorts does not include \"http\", but all three probes target that port - the group would never become Ready. Every group needs it, including an SMTP-only one; it is simply not published there" $group) -}}
{{- end -}}
{{- end -}}
{{- toYaml $nodes -}}
{{- end -}}
{{/*
The effective resources of one node group.
Input: dict "group" <name> "given" <the group's resources block|nil>
"limitFactor" <the chart-global .Values.limitFactor|nil>.
The per-group defaults are reproduced here, not a single generic set: "mx" is
deliberately lighter than "full", and a fallback that handed an erased
mx.resources the heavier figures would quietly change what the chart had been
rendering. A group the chart does not ship gets the "full" figures, which is
the safe direction - too much accounting rather than too little.
*/}}
{{- define "stalwart.effectiveResources" -}}
{{- $factor := 3 -}}
{{- if not (kindIs "invalid" .limitFactor) -}}
{{- $factor = .limitFactor -}}
{{- end -}}
{{- $requests := dict "cpu" "200m" "memory" "512Mi" "ephemeral-storage" "64Mi" -}}
{{- if eq .group "mx" -}}
{{- $requests = dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi" -}}
{{- end -}}
{{- $defaults := dict "limitFactor" $factor "requests" $requests -}}
{{- $merged := fromYaml (include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s.resources" .group))) -}}
{{- include "stalwart.resources" $merged -}}
{{- end -}}
{{/*
The label a pod carries for one port it is reachable on. The per-port Services
select on these, which is what lets one NodePort Service serve several node
groups - NodePorts are cluster-globally unique, so "full" and "mx" can only
accept SMTP behind the SAME Service object.
*/}}
{{- define "stalwart.portLabel" -}}
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
{{- end -}}
{{/*
The address of the in-memory store, and the render abort that goes with it.
Input: the root context. Returns the host, or "" when none is needed.
The abort is the point. From v0.16 the cluster coordinator's Default variant
"reuses the Redis connection already configured on the in-memory store", so
more than one node without an in-memory store is a cluster with no
coordination path - and the symptom is not a failed install but a cluster that
comes up and disagrees with itself. Note what this value does NOT do: it does
not configure Stalwart. The in-memory store is an object in the database; this
is the address to type into the WebUI, and the port for the egress rule.
*/}}
{{- define "stalwart.valkeyHost" -}}
{{- $given := "" -}}
{{- if not (kindIs "invalid" .Values.externalServices) -}}
{{- $valkey := .Values.externalServices.valkey -}}
{{- if and (not (kindIs "invalid" $valkey)) (not (kindIs "invalid" $valkey.host)) -}}
{{- $given = $valkey.host -}}
{{- end -}}
{{- end -}}
{{- $subchartOn := false -}}
{{- if and (not (kindIs "invalid" .Values.valkey)) .Values.valkey.enabled -}}
{{- $subchartOn = true -}}
{{- end -}}
{{- if $given -}}
{{- $given -}}
{{- else if $subchartOn -}}
{{/* The subchart's own fullname helper: release name plus chart name, unless
the release name already contains it. Reproduced rather than called,
because a parent cannot include a subchart's define with the subchart's
context. */}}
{{- if contains "valkey" .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-valkey" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- else -}}
{{- $total := 0 -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) -}}
{{- $total = add $total (int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas))) -}}
{{- end -}}
{{- if gt $total 1 -}}
{{- fail (printf "this release runs %d Stalwart pods, which is a cluster, but no in-memory store has an address: externalServices.valkey.host is empty and valkey.enabled is false. Stalwart's cluster coordinator rides on the in-memory store connection, so a multi-node install has no coordination path at all - and that failure does not show up as a failed install, it shows up as nodes that do not agree with each other. Set externalServices.valkey.host to an existing instance, or set valkey.enabled: true to deploy the subchart" $total) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
The environment-variable names that config.json references and the StatefulSet
sets. They are helpers rather than literals in two files because a mismatch
between the two is not a render error: the server starts, resolves authSecret,
finds no such variable, and fails to open the data store with
"Environment variable '<name>' not found". One definition, used by both.
Note the upstream resolver treats an EMPTY value as not found, so a 1Password
item whose field exists but is blank fails exactly like a missing field.
*/}}
{{- define "stalwart.env.dbPassword" -}}STALWART_DB_PASSWORD{{- end -}}
{{- define "stalwart.env.s3AccessKey" -}}STALWART_S3_ACCESS_KEY{{- end -}}
{{- define "stalwart.env.s3SecretKey" -}}STALWART_S3_SECRET_KEY{{- end -}}
{{/*
The node group the Ingress routes to, validated. Input: the root context.
*/}}
{{- define "stalwart.ingressNodeGroup" -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $group := required "ingress.nodeGroup must name the node group the Ingress routes to" .Values.ingress.nodeGroup -}}
{{- if not (hasKey $nodes $group) -}}
{{- fail (printf "ingress.nodeGroup is %q, which is not a key of nodes: - known groups are %s" $group (keys $nodes | sortAlpha | join ", ")) -}}
{{- end -}}
{{- $group -}}
{{- end -}}
@@ -0,0 +1,56 @@
{{- /*
The certificate for the MAIL ports, issued by cert-manager and mounted into
every node group read-only.
WHY THE INGRESS CERTIFICATE IS NOT ENOUGH: the Ingress covers the WebUI and
JMAP only. The HA proxy in front of the cluster terminates no TLS, so Stalwart
itself has to present a certificate for STARTTLS on 25 and implicit TLS on 465
and 993. Those connections never pass through the ingress controller.
WHY NOT STALWART'S OWN ACME: the HA proxy forwards 25, 465 and 993 and not 80,
so an HTTP-01 challenge answered by Stalwart is unreachable from outside.
DNS-01 inside Stalwart would work and would duplicate what cert-manager
already does here.
THIS OBJECT ONLY OBTAINS THE FILES. Pointing Stalwart at them is a separate,
manual step: the TLS object in the DATABASE has to name the two paths below.
Until it does, the mail ports serve whatever default certificate Stalwart
generates for itself, and clients see a name mismatch while every Kubernetes
object here is green.
*/ -}}
{{- if .Values.certificate.enabled }}
{{- $domain := required "A Domain/Host must be provided (ingress.domain) - it is read here for the mail certificate as well as by the Ingress, so it stays required even with ingress.enabled false" .Values.ingress.domain -}}
{{- /* An explicit if, NOT
.Values.certificate.clusterIssuer | default (required "..." .Values.ingress.clusterIssuer)
which is the obvious one-liner and is wrong: Go evaluates a pipeline's
arguments eagerly, so required() fires whenever ingress.clusterIssuer is
null even though certificate.clusterIssuer is set and the fallback is
never needed. Measured: ingress.enabled=false plus
certificate.clusterIssuer=<issuer> aborted the render. */ -}}
{{- $issuer := .Values.certificate.clusterIssuer -}}
{{- if not $issuer -}}
{{- $issuer = required "A Cluster-Issuer must be provided: set certificate.clusterIssuer for the mail certificate (usually a DNS-01 issuer), or ingress.clusterIssuer, which it falls back to" .Values.ingress.clusterIssuer -}}
{{- end -}}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "stalwart.fullname" . }}-mail-certificate
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Deliberately NOT the Ingress TLS secret name (tls-<release>-stalwart-ingress).
# Two separate certificates on purpose: this one usually comes from a
# different issuer (DNS-01, see below) and covers different names, and
# sharing one secret would make the ingress controller and the mail
# listeners fight over which names the certificate has to carry.
secretName: tls-{{ include "stalwart.fullname" . }}-mail
issuerRef:
name: {{ $issuer }}
kind: ClusterIssuer
group: cert-manager.io
dnsNames:
- {{ $domain | quote }}
{{- range .Values.certificate.extraDnsNames }}
- {{ . | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,52 @@
{{- /*
config.json - the ONLY file Stalwart reads from disk, and the only part of its
configuration this chart owns.
It is exactly the DataStore object, with no wrapper: upstream's declarative
deployment page states "It only needs to contain the DataStore object", and the
reader confirms it - crates/store/src/registry/local.rs deserializes the whole
file straight into the DataStore enum, which is internally tagged on "@type".
Everything else (listeners, blob store, in-memory store, TLS objects, domains,
cluster roles) is a JMAP object in the database and is created in the WebUI.
WHY A CONFIGMAP AND NOT A SECRET: there is no credential in here. authSecret
is a tagged sub-object with an EnvironmentVariable variant, so the password is
referenced by NAME and arrives through the env (see the StatefulSet). That is
what keeps checksum/config meaningful - Helm can hash this file because Helm
can see all of it.
The username is NOT a secret and cannot be made one: authUsername is a plain
String on the object with no secret form, unlike authSecret. It is public here
by necessity, not by oversight.
*/ -}}
{{- $pg := .Values.dataStore.postgres -}}
{{- $store := dict
"@type" "PostgreSql"
"host" (required "dataStore.postgres.host is required - without it Stalwart has no database, and a config.json that cannot be parsed or reached is the one case that does abort the start loudly" $pg.host)
"port" (int $pg.port)
"database" $pg.database
"authUsername" $pg.username
"authSecret" (dict "@type" "EnvironmentVariable" "variableName" (include "stalwart.env.dbPassword" .))
"useTls" $pg.tls.enable
"allowInvalidCerts" $pg.tls.allowInvalidCerts
"poolMaxConnections" (int $pg.poolMaxConnections)
"timeout" (int $pg.timeoutMs) -}}
{{- if not (kindIs "invalid" $pg.options) -}}
{{- $_ := set $store "options" $pg.options -}}
{{- end -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "stalwart.fullname" . }}-configmap
labels:
app: {{ include "stalwart.fullname" . }}
data:
# Rendered through toPrettyJson from a dict rather than written as a JSON
# literal: "timeout" is a Duration, which upstream's object encoding defines
# as an INTEGER COUNT OF MILLISECONDS and which explicitly "does not accept a
# string" - the serde impl deserializes it through u64::deserialize into
# Duration::from_millis. A hand-written literal is where a "15s" creeps in,
# and the server then refuses to parse the file at startup. Going through the
# dict also means "port" and "poolMaxConnections" cannot come out quoted.
config.json: |
{{- toPrettyJson $store | nindent 4 }}
+65
View File
@@ -0,0 +1,65 @@
{{- /*
The Ingress for the WebUI and JMAP only. SMTP, IMAP and ManageSieve are not
HTTP and cannot pass through it - those reach the pods through the per-port
NodePort Services.
It targets exactly ONE node group (ingress.nodeGroup) through that group's own
http Service, never a port-label selector: the management surface is not safe
to spread across a group that may not run the management tasks.
TWO TRAPS THAT BELONG TO THIS OBJECT AND ARE NOT SOLVED BY IT:
1. The auto-ban sees the INGRESS CONTROLLER's pod IP for every JMAP and WebUI
request, not the client's. Sooner or later it bans that address and the
WebUI and JMAP go dark for everyone at once. The PROXY protocol is not the
remedy for HTTP: the remedy is Stalwart's "forwarded" handling with
X-Forwarded-For evaluation, plus discarding whatever forwarding headers the
client brought along. That is a configuration step in the database, and it
is mandatory rather than optional - the failure arrives weeks later and
looks like a server problem.
2. AFTER BOOTSTRAP, PORT 8080 IS NOT THE ADMIN PORT ANY MORE. Upstream:
"After the restart, the WebUI is reached at https://<hostname>/admin on the
configured server hostname, and HTTP access on port 8080 is no longer
available for administration." So this Ingress only works once an HTTP
NetworkListener on 8080 exists in the database. Between finishing the setup
and creating that listener, the Ingress resolves to a port that answers
nothing.
*/ -}}
{{- if .Values.ingress.enabled }}
{{- $group := include "stalwart.ingressNodeGroup" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $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 Ingress to point at" $group $group) -}}
{{- end -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "stalwart.fullname" . }}-ingress
labels:
app: {{ include "stalwart.fullname" . }}
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
http:
paths:
- backend:
service:
# The http Service of the "{{ $group }}" group only.
name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ include "stalwart.fullname" . }}-ingress
{{- end }}
@@ -0,0 +1,136 @@
{{- /*
One NetworkPolicy per node group.
This object is the CEILING ON REACHABILITY, and that is its real job here: it
holds no matter which ports the process actually bound. A node whose
ClusterRole is missing from the database fail-opens into a full node and binds
every listener - including IMAP and the admin interface on a group meant to
take SMTP only - while staying Running with a green probe. The per-port
Services publish only what was asked for, and this policy is the second,
independent limit. See values.yaml for the source-level detail.
!!! THE PORT NUMBERS HERE ARE POD PORTS (25/465/993/8080), NOT NODEPORTS !!!
This is the easiest mistake to make in this chart, so it is spelled out. A
NetworkPolicy is evaluated at the POD's network interface, after kube-proxy has
already translated the NodePort to the targetPort. Writing 30025 here would
match nothing: the packet arrives at the pod on 25. The policy would then look
strict, be accepted by the API server, and silently block every inbound
connection - or, if it is the only rule, block the lot.
Egress is restricted BY PORT and not by destination, because a NetworkPolicy
matches CIDRs and selectors and never hostnames - so dataStore.postgres.host
and externalServices.*.host cannot appear in it. What it still buys: a
compromised pod cannot open arbitrary outbound ports.
And a NetworkPolicy is only a boundary where the cluster's CNI enforces one. An
unenforced policy is accepted and filters nothing.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $egress := .Values.networkPolicy.egress -}}
{{- $probeSources := .Values.networkPolicy.probeSources | default list -}}
{{- $allowedNs := .Values.networkPolicy.allowedNamespaces | default list -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ $name }}-networkpolicy
labels:
app: {{ $name }}
spec:
podSelector:
matchLabels:
app: {{ $name }}-sfs
policyTypes:
- Ingress
- Egress
ingress:
{{- /* The public mail ports: open to any source. They have to be - the
senders are the whole internet, and after NodePort masquerading the
source address is a control-plane node anyway. */}}
{{- $publicPorts := list }}
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
{{- if $port.public }}{{- $publicPorts = append $publicPorts $portName }}{{- end }}
{{- end }}
{{- if $publicPorts }}
- ports:
{{- range $portName := $publicPorts }}
{{- $port := index $ports $portName }}
# {{ $portName }}: published on nodePort {{ int $port.nodePort }}, reaching the pod on {{ int $port.containerPort }}
- protocol: {{ $port.protocol | default "TCP" }}
port: {{ int $port.containerPort }}
{{- end }}
{{- end }}
{{- /* The http port: probes plus the ingress controller. */}}
{{- $http := index $ports "http" }}
- ports:
- protocol: {{ $http.protocol | default "TCP" }}
port: {{ int $http.containerPort }}
{{- if or $probeSources $allowedNs }}
# Restricted because networkPolicy.probeSources or .allowedNamespaces is
# set. MIND THE KUBELET: probes come from the IP of the worker node
# running the pod, so that address has to be in probeSources or the pod
# never becomes Ready - and a pod that is never Ready reads as a broken
# application rather than as a policy.
from:
- namespaceSelector:
matchLabels:
# Set by Kubernetes itself on every namespace (>= 1.21), so this
# needs no labelling work.
kubernetes.io/metadata.name: {{ $root.Release.Namespace }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
# NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside
# the cluster: http carries no nodePort, so nothing outside reaches it
# through this chart. The alternative was to guess the worker node CIDRs,
# and a wrong guess blocks every kubelet probe. Narrow it by setting
# networkPolicy.probeSources (and then .allowedNamespaces for the
# ingress controller, or the Ingress goes dark).
{{- end }}
egress:
{{- if $egress.dns }}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- end }}
{{- if $egress.smtp }}
# Outbound delivery: port 25 on any host on the internet. Uniform for every
# group on purpose - the chart does not know which groups run the
# outboundMta task, the value would drift away from the database, and the
# failure mode of getting it wrong is mail silently not leaving.
- ports:
- protocol: TCP
port: 25
{{- end }}
{{- $outPorts := list (int $root.Values.dataStore.postgres.port) }}
{{- with $root.Values.externalServices }}
{{- if .s3 }}{{- $outPorts = append $outPorts (int .s3.port) }}{{- end }}
{{- if .valkey }}{{- $outPorts = append $outPorts (int .valkey.port) }}{{- end }}
{{- end }}
{{- range $root.Values.networkPolicy.egress.extraPorts }}
{{- $outPorts = append $outPorts (int .) }}
{{- end }}
# The data store, the blob store, the in-memory store and anything added
# through networkPolicy.egress.extraPorts. Ports only - see the header.
- ports:
{{- range $outPorts | uniq }}
- protocol: TCP
port: {{ . }}
{{- end }}
{{- end }}
{{- end }}
+75
View File
@@ -0,0 +1,75 @@
{{- /*
The 1Password items this chart materializes. Each is rendered only when its
op:// path is set, because two of the three are genuinely optional.
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
materializes one Secret key per FIELD of the item, and these templates mount or
reference those keys by name. An item without the expected field leaves the
container in CreateContainerConfigError. NOTES.txt lists them for the installer.
AND THE LIMIT THAT CANNOT BE CLOSED: a change to the CONTENT of any of these
items does not restart anything. Helm never sees a secret's value - the
operator fills the Secret in-cluster after rendering - so there is nothing to
hash. What this chart does cover is the op:// PATH, through checksum/secret-ref
on the pod template (see the StatefulSet): repointing one of the values below
at a different vault item does roll the pods. For a content change, and after
rotating a password inside the item, the manual step is
kubectl -n <ns> rollout restart statefulset/<release>-stalwart-<group>-sfs
for every group. The 1Password operator's auto-restart annotation is NOT set
here and must not be: verified against operator 1.12.0 it enumerates
Deployments only, so on a StatefulSet it is a silent no-op that advertises a
guarantee it does not provide (README, guideline 9).
*/ -}}
{{- if .Values.secrets.dataStoreSecretRef }}
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-datastore-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the field: password
#
# Only the password. The data store's username is dataStore.postgres.username
# and is rendered into the ConfigMap, because authUsername is a plain String
# on the DataStore object while authSecret is a SecretKeyOptional - upstream
# offers a secret form for one and not the other.
itemPath: {{ .Values.secrets.dataStoreSecretRef }}
{{- end }}
{{- if .Values.secrets.recoveryAdminSecretRef }}
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-recovery-admin-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the field: recovery-admin
#
# Holding the COMPLETE value of STALWART_RECOVERY_ADMIN as one string,
# "admin:<password>". The chart deliberately does not assemble it from a
# username and a password field: 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" rather than failing - and this credential
# is a back door that logs in regardless of directory state, which is not
# something to be uncertain about in either direction.
itemPath: {{ .Values.secrets.recoveryAdminSecretRef }}
{{- end }}
{{- if .Values.secrets.s3SecretRef }}
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ include "stalwart.fullname" . }}-s3-secret
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Must carry the fields: access-key, secret-key
#
# This chart does not configure the blob store - that object lives in the
# database. It exposes both fields to the container as {{ include "stalwart.env.s3AccessKey" . }}
# and {{ include "stalwart.env.s3SecretKey" . }}, so the BlobStore object created in the WebUI can
# reference them by name instead of storing the credentials in the database.
itemPath: {{ .Values.secrets.s3SecretRef }}
{{- end }}
+123
View File
@@ -0,0 +1,123 @@
{{- /*
Three kinds of Service, and the split is forced rather than stylistic.
1. ONE NodePort Service PER PUBLIC PORT, not per node group. A NodePort is
unique across the whole cluster, so "full" and "mx" can only accept SMTP
behind the SAME Service object - two Services asking for 30025 is a
conflict. Hence the selector matches a per-port LABEL on the pods instead
of a node group, which lets one Service cover every group that lists the
port and still exclude the ones that do not.
Putting all the ports into one Service does not work either: a Service has
exactly one selector, so 30993 would then be routed to "mx" pods as well,
where nothing is listening.
2. ONE ClusterIP http Service PER NODE GROUP. The Ingress must reach exactly
one group (ingress.nodeGroup): 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. So http gets a
per-group Service and the Ingress names one of them.
3. ONE headless Service PER NODE GROUP, for the StatefulSet's stable pod DNS.
*/ -}}
{{- $root := . -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $release := include "stalwart.fullname" . -}}
{{- /* 1. Per-port NodePort Services. */ -}}
{{- range $portName, $port := $ports }}
{{- if $port.public }}
{{- $groups := list }}
{{- range $group, $spec := $nodes }}
{{- if has $portName $spec.exposedPorts }}{{- $groups = append $groups $group }}{{- end }}
{{- end }}
{{- if $groups }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $release }}-{{ $portName }}-service
labels:
app: {{ $release }}
{{- with $root.Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: NodePort
# Cluster, never Local. No Stalwart pod is ever scheduled on a control-plane
# node, and the HA proxy delivers to the control-plane nodes - under Local
# every packet would be dropped, not some. The cost is that kube-proxy
# masquerades the source address; the PROXY v2 header the HA proxy prepends
# is what makes that harmless. values.yaml has the full reasoning and the
# list of what a lost source address breaks on a mail server.
externalTrafficPolicy: {{ $root.Values.service.externalTrafficPolicy }}
selector:
# Scoped to this release, then to the port. Node groups serving this port:
# {{ $groups | sortAlpha | join ", " }}
stalwart.jk-dev.de/release: {{ $release }}
{{ include "stalwart.portLabel" $portName }}: "true"
ports:
- name: {{ $portName }}
protocol: {{ $port.protocol | default "TCP" }}
# port and nodePort are the published number; targetPort is the number
# the application binds inside the pod. They differ here on purpose.
port: {{ int $port.nodePort }}
nodePort: {{ int $port.nodePort }}
targetPort: {{ $portName }}
{{- end }}
{{- end }}
{{- end }}
{{- /* 2. + 3. Per-group http ClusterIP and headless Services. */ -}}
{{- range $group, $spec := $nodes }}
{{- $name := printf "%s-%s" $release $group }}
{{- if has "http" $spec.exposedPorts }}
{{- $http := index $ports "http" }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}-http-service
labels:
app: {{ $name }}
{{- with $root.Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: ClusterIP
selector:
app: {{ $name }}-sfs
ports:
- name: http
protocol: {{ $http.protocol | default "TCP" }}
port: {{ int $http.containerPort }}
targetPort: http
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}-headless
labels:
app: {{ $name }}
spec:
# Headless: this exists for the StatefulSet's stable per-pod DNS names
# (<pod>.<release>-stalwart-<group>-headless), not for load balancing.
clusterIP: None
# So the DNS names resolve before a pod is Ready - a cluster member has to be
# findable by its peers while it is still starting up.
publishNotReadyAddresses: true
selector:
app: {{ $name }}-sfs
ports:
{{- range $portName := $spec.exposedPorts }}
{{- $port := index $ports $portName }}
- name: {{ $portName }}
protocol: {{ $port.protocol | default "TCP" }}
port: {{ int $port.containerPort }}
targetPort: {{ $portName }}
{{- end }}
{{- end }}
+266
View File
@@ -0,0 +1,266 @@
{{- /*
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 }}