{{- /* 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 }}