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
This commit is contained in:
1 parent
ee43c33ef4
commit
780cb8eafd
19 files changed
+3007
-3
No files matched your search
@@ -0,0 +1,76 @@
|
||||
apiVersion: v2
|
||||
name: stalwart
|
||||
description: A Helm chart to deploy a cluster-capable Stalwart mail server (SMTP, IMAP, JMAP, ManageSieve) on Kubernetes
|
||||
icon: https://avatars.githubusercontent.com/u/76623657
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag, and the "v" is part of it - so it is part
|
||||
# of appVersion and of the "+up" suffix. Precedent: calibre-web-automated
|
||||
# (+upv4.0.6), matrix-synapse (+upv1.159.0), ytdlp-web-player (+upv1.2.0).
|
||||
#
|
||||
# The glibc image, not "-alpine". Both flavours are published for every release
|
||||
# from the same source and carry the same uid/gid 2000 and the same two volumes;
|
||||
# the choice is a deployment decision, and keeping it out of the tag is what
|
||||
# lets renovate.json5 anchor the version with a plain three-part regex instead
|
||||
# of maintaining a compatibility suffix. The moving tags "latest", "v0.16" and
|
||||
# their -alpine twins are excluded by that same anchor: measured on 2026-10-02
|
||||
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
|
||||
# apart, so either floating name would change the running image with no commit
|
||||
# in this repository and nothing to report it.
|
||||
version: 1.0.0+upv0.16.24
|
||||
appVersion: "v0.16.24"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
|
||||
# The second chart in this repo with a dependency (after ytdlp-web-player).
|
||||
# README, "Chart dependencies", holds the bar it has to clear and the price;
|
||||
# both are restated here because the price is paid by whoever reads this file
|
||||
# next, not by whoever added the line.
|
||||
#
|
||||
# WHY ONE HERE. Stalwart's in-memory store is not a cache that can be dropped:
|
||||
# from v0.16 it also carries the cluster coordinator ("the Default variant
|
||||
# reuses the Redis connection already configured on the in-memory store"), so a
|
||||
# multi-node install does not work without it. Redis/Valkey is admissible as
|
||||
# the in-memory store ONLY - never as the data or blob store. Offering it inside
|
||||
# the chart means the wiring (host, port, egress rule) is in one place instead
|
||||
# of split across two releases that have to agree.
|
||||
#
|
||||
# WHY IT IS OFF BY DEFAULT, and why that is more than convention here: most
|
||||
# installations already run a Valkey, and this repo publishes a valkey chart of
|
||||
# its own that is the better choice when one application owns the instance.
|
||||
# Point externalServices.valkey at it and this dependency renders nothing.
|
||||
#
|
||||
# UPSTREAM'S CHART IS CONSUMED AS IT COMES (guideline 14.2). Two of its defaults
|
||||
# fall short of this repo's conventions and are deliberately NOT patched here;
|
||||
# they are documented in our values.yaml under "valkey:" instead, where the
|
||||
# values-level fix stays available to whoever installs the chart.
|
||||
dependencies:
|
||||
- name: valkey
|
||||
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has.
|
||||
#
|
||||
# A subchart is invisible to everything downstream that watches Helm release
|
||||
# versions: what this file declares is baked into our packaged .tgz, and
|
||||
# Fleet only ever sees the version of "stalwart". So a new valkey chart - a
|
||||
# security fix included - reaches a cluster ONLY when this line is raised
|
||||
# and a new stalwart version is published. Nothing reports the gap.
|
||||
#
|
||||
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR (no
|
||||
# customManagers entry exists or would help - repository and version sit
|
||||
# together in one file, which is the case that manager is for), and
|
||||
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
|
||||
# actually ships it. That chain is the whole safety net; switched off, this
|
||||
# line needs a human on a calendar instead.
|
||||
#
|
||||
# Independent of appVersion: raising it is a patch or minor bump of this
|
||||
# chart and is never tied to the Stalwart image.
|
||||
version: "0.12.0"
|
||||
# The Valkey project's own chart, so upstream owns the schema - not a
|
||||
# re-packaging. Note the repository is the plain HTTP index, not OCI.
|
||||
repository: "https://valkey.io/valkey-helm"
|
||||
# Off by default, so a plain install renders none of its objects. Helm
|
||||
# splits a condition on dots only; this name carries no dash, so unlike
|
||||
# ytdlp-web-player's oauth2-proxy it is also reachable as .Values.valkey.*
|
||||
# inside templates without index.
|
||||
condition: valkey.enabled
|
||||
Reference in new issue
Block a user