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