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,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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
Reference in new issue
Block a user