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
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:
19 files changed
+3007
-3
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# Fixtures for the stalwart install-test: a throwaway Postgres, the one Secret
|
||||
# the 1Password operator would otherwise materialize, and a stub CRD for
|
||||
# cert-manager's Certificate (release name: ci; the OnePasswordItem stub CRD
|
||||
# comes from ci/common/).
|
||||
#
|
||||
# NO VALKEY HERE, DELIBERATELY. The in-memory store is an object in Stalwart's
|
||||
# DATABASE, not a chart value, so a Valkey in this namespace would be reachable
|
||||
# and unused - it would make the test look like it covered the clustering path
|
||||
# while covering nothing of it. The test runs a single node (nodes.full
|
||||
# replicas 1, nodes.mx replicas 0), which is the configuration that needs no
|
||||
# in-memory store at all. The subchart path is covered by a local render only,
|
||||
# named as a known gap in the PR that introduced this chart.
|
||||
#
|
||||
# A stub CRD for Certificate rather than certificate.enabled: false, because
|
||||
# switching the Certificate off would also drop the volume and volumeMount that
|
||||
# carry it - and the mount is on the risky side of readOnlyRootFilesystem. With
|
||||
# the stub the object applies, the Secret it names never appears, and the
|
||||
# optional: true on the volume is exercised for real: the pods must start
|
||||
# without it. That is the behaviour an operator meets on every first install,
|
||||
# before cert-manager has answered the challenge.
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: certificates.cert-manager.io
|
||||
spec:
|
||||
group: cert-manager.io
|
||||
names:
|
||||
kind: Certificate
|
||||
listKind: CertificateList
|
||||
plural: certificates
|
||||
singular: certificate
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-stalwart-datastore-secret
|
||||
stringData:
|
||||
# The ONLY field this chart reads from the data store item. config.json
|
||||
# references it by variable name through
|
||||
# "authSecret": {"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}
|
||||
# so this value has to match the throwaway Postgres below. A real password
|
||||
# rather than an empty string on purpose: upstream's resolver treats an empty
|
||||
# environment variable as "not found" and fails the store open, so an empty
|
||||
# value here would fail the run for the right reason in the wrong place.
|
||||
password: ci-stalwart-throwaway-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: public.ecr.aws/docker/library/postgres:16-alpine
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: stalwart
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: ci-stalwart-throwaway-password
|
||||
- name: POSTGRES_DB
|
||||
value: stalwart
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "stalwart"]
|
||||
periodSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
@@ -0,0 +1,123 @@
|
||||
# CI values for the stalwart install-test.
|
||||
#
|
||||
# WHAT THIS TEST CAN AND CANNOT PROVE — read this before trusting a green run.
|
||||
#
|
||||
# It CANNOT prove a working mail server, and no install-test here could. Since
|
||||
# v0.16 every listener, the blob store, the in-memory store, the TLS object and
|
||||
# the cluster roles live in Stalwart's DATABASE and are created by a human in
|
||||
# the WebUI. Against an empty database there is no SMTP service to test, so a
|
||||
# green run says nothing about mail.
|
||||
#
|
||||
# What it DOES prove is the part that is the chart's own responsibility, and the
|
||||
# first item is the reason this chart is tested rather than excluded:
|
||||
#
|
||||
# 1. THE CONTAINER EXECS AT ALL. The image carries
|
||||
# "setcap cap_net_bind_service=+ep" on its binary, which makes the kernel
|
||||
# fail execve itself when the capability is not in the bounding set. The
|
||||
# first draft of this chart had capabilities.drop: [ALL] with nothing
|
||||
# added and would have failed with "operation not permitted" in every
|
||||
# install - caught by reading the Dockerfile, but exactly the class of
|
||||
# failure an install-test exists to catch rather than reason about.
|
||||
# 2. readOnlyRootFilesystem: true holds with the two image volumes plus /tmp
|
||||
# as emptyDirs, and the init container really can write config.json into
|
||||
# the /etc/stalwart emptyDir.
|
||||
# 3. The rendered config.json parses AS STALWART READS IT, not just as JSON:
|
||||
# the server opens the data store, which means "@type": "PostgreSql", the
|
||||
# integer-millisecond timeout and the authSecret EnvironmentVariable
|
||||
# indirection are all accepted. This is the one check that the password
|
||||
# really arrives without ever being in the ConfigMap.
|
||||
# 4. The Secret key names match what the templates reference, the ConfigMap
|
||||
# mount works, the NodePorts can be allocated, the resource quantities are
|
||||
# valid, the schema accepts a realistic (non-default) values file, and the
|
||||
# NetworkPolicy objects are accepted without cutting off the probes.
|
||||
#
|
||||
# RECOVERY MODE IS ON, and that is not a shortcut - it is the only mode that can
|
||||
# work here. The bootstrap wizard appears only when config.json is ABSENT, and
|
||||
# this chart always renders one; upstream's documented path for a templated
|
||||
# config.json is to start once with STALWART_RECOVERY_MODE=1. Recovery mode
|
||||
# brings up the management HTTP endpoint and keeps the mail ports closed, which
|
||||
# is precisely the reachable surface the probes use.
|
||||
#
|
||||
# THE ONE THING THIS FILE COULD NOT BE VERIFIED AGAINST: whether /healthz/live
|
||||
# and /healthz/ready are served on the RECOVERY listener. The source shows them
|
||||
# handled in the general HTTP request path with no auth and no network
|
||||
# restriction, but recovery mode binds a single listener and skips most config
|
||||
# parsing, and there is no cluster in the authoring environment to try it on.
|
||||
# If the install-test goes red with the pods never becoming Ready, that is the
|
||||
# reason, and the fix is either an exec probe or an entry in
|
||||
# ci/excluded-charts.txt - see the PR for #65.
|
||||
bootstrap:
|
||||
enabled: true
|
||||
logLevel: debug
|
||||
|
||||
dataStore:
|
||||
postgres:
|
||||
host: postgres
|
||||
database: stalwart
|
||||
username: stalwart
|
||||
tls:
|
||||
# OFF, against the chart's own default of true. The throwaway Postgres in
|
||||
# fixtures.yaml serves no TLS, and the alternative - allowInvalidCerts -
|
||||
# would be worse: it would pass whether or not TLS worked, so a chart
|
||||
# change that broke the TLS path would still go green. A deviation that
|
||||
# fails loudly beats one that hides.
|
||||
enable: false
|
||||
allowInvalidCerts: false
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/stalwart/fixtures.yaml as ci-stalwart-datastore-secret.
|
||||
dataStoreSecretRef: op://ci/stalwart-datastore
|
||||
# Both deliberately unset: neither is needed to bring a pod up, and leaving
|
||||
# them out exercises the conditional env and OnePasswordItem blocks in the
|
||||
# "absent" direction, which is the shape a minimal install really has.
|
||||
recoveryAdminSecretRef: null
|
||||
s3SecretRef: null
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the per-group http Service it names actually
|
||||
# exists - a selector typo there is otherwise invisible until someone opens
|
||||
# the URL.
|
||||
enabled: true
|
||||
domain: stalwart.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
nodeGroup: full
|
||||
|
||||
certificate:
|
||||
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
|
||||
# which is the interesting case: the volume is mounted optional, so the pods
|
||||
# must start without it. That is what every real first install looks like
|
||||
# before cert-manager has answered the challenge.
|
||||
enabled: true
|
||||
clusterIssuer: ci-selfsigned
|
||||
|
||||
networkPolicy:
|
||||
# Left ON. probeSources stays empty, so the http rule renders without a
|
||||
# "from" selector - the configuration this chart ships. Whether the policy
|
||||
# actually FILTERS is a property of the cluster's CNI and is not claimed by
|
||||
# this test; what is claimed is that the objects are accepted and that they
|
||||
# do not keep the kubelet's probes from working.
|
||||
enabled: true
|
||||
|
||||
nodes:
|
||||
full:
|
||||
replicas: 1
|
||||
exposedPorts: [smtp, submissions, imaps, http]
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the
|
||||
# limit derivation: 256Mi x limitFactor 3 = 768Mi.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 64Mi
|
||||
mx:
|
||||
# Stays at 0, which is also the chart default. Two reasons rather than one:
|
||||
# a second node would need an in-memory store (the render aborts without
|
||||
# one), and the "mx" ClusterRole does not exist in an empty database, so a
|
||||
# running mx pod would fail-open into a second full node and the two would
|
||||
# fight over the same NodePort-backed SMTP Service. The StatefulSet is
|
||||
# still created and the harness still waits on it - a 0-replica rollout
|
||||
# completes immediately, so this covers the explicit-zero path of the
|
||||
# replicas helper.
|
||||
replicas: 0
|
||||
exposedPorts: [smtp, http]
|
||||
Reference in new issue
Block a user