feat(stalwart): spread each node group over nodes and give each one a PDB
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 7s
SonarQube Analysis / SonarQube Analysis (push) Successful in 27s

A two-replica node group was redundancy on paper: nothing kept both pods off
one node, and no PodDisruptionBudget kept a drain from taking both at once.
Both objects are now rendered per node group, gated on that group's COMPUTED
replica count being >= 2.

The distribution is a promise, not an effort: maxSkew 1 on
kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only
this group's own pods, so an "mx" and a "full" pod may share a node. The
selector comes from the chart (stalwart.groupLabels), never from the operator -
a spread constraint whose selector matches no pod is trivially satisfied,
distributes nothing and reports nothing.

nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing
detail. Its API default is Honor, under which a narrowing nodeSelector shrinks
the set of counted domains until both replicas fit into one: skew 0, both pods
co-located, no Pending, no event, and a selector that is correct - so no check
can see it. With Ignore the same narrowing gives skew 2 and the second pod is
Pending. A render abort was the alternative and would also have forbidden a
nodeSelector spanning five nodes, which is legal.

A user entry colliding with the chart's own on (topologyKey,
whenUnsatisfiable) aborts the render naming both values, because the API
rejects the duplicate with a message about a list index and an apply-based path
merges the two over patchMergeKey: topologyKey silently, dropping one selector.

Minor rather than major, proved with a diff rather than argued: with the
shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml,
helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both
diffs empty.

New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds
every rendered PodDisruptionBudget selector and every topologySpreadConstraints
labelSelector against the labels of the pod templates in the same render. Helm
itself is the YAML parser (ci/yaml2json), because the runners have neither
pyyaml nor yq and a hand-written parser could read nothing and report nothing.
podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors
are reported as not checked rather than counted as passing. Two anti-vacuum
guards: a chart with an overlay under ci/selector-match/ that renders no
selector fails, and a run that checks no selector at all fails.

Fixes #96
This commit was merged in pull request #97.
This commit is contained in:
jklein authored and jklein committed 2026-10-07 19:36:04 +00:00
1 parent 1698fc0aeb
commit 6ade74db80
14 files changed
+1125 -8

No files matched your search

+43 -5
View File
@@ -23,6 +23,8 @@ are emptyDirs. That is also why there is no fsGroup in the pod context.
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
{{- $replicas := int (include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas)) }}
{{- $settings := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
---
{{ if $valkeyHost -}}
# In-memory store this release expects: {{ $valkeyHost }}:{{ $root.Values.externalServices.valkey.port }}
@@ -36,10 +38,9 @@ metadata:
name: {{ $name }}-sfs
labels:
app: {{ $name }}-sfs
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
stalwart.jk-dev.de/node-group: {{ $group }}
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 4 }}
spec:
replicas: {{ include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas) }}
replicas: {{ $replicas }}
serviceName: {{ $name }}-headless
selector:
matchLabels:
@@ -51,8 +52,15 @@ spec:
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 }}
{{- /* These same two labels are also what the group's
PodDisruptionBudget and the chart's topology spread constraint
select on, which is why they come from ONE definition: a
selector matching no pod fails silently in both of those objects
- see the helper's comment. Kept as a rendered YAML comment above
rather than folded in here so that the manifest this chart
produced before Gitea #96 and the one it produces now are
byte-identical for the default values. */}}
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 8 }}
{{- /* 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
@@ -81,6 +89,36 @@ spec:
automountServiceAccountToken: false
securityContext:
{{- include "stalwart.podSecurityContext" $root.Values.securityContext | nindent 8 }}
{{- /* SCHEDULING. The first three are pass-through: the chart never reads
their contents, Kubernetes owns their schema, and an empty block
renders nothing at all rather than an empty field.
MIND THE COMBINATION WITH THE SPREAD CONSTRAINT BELOW. A
nodeSelector or an affinity that narrows the eligible nodes below
the group's replica count turns the constraint into a permanent
Pending - which is the INTENDED, loud outcome (see values.yaml),
but it reads like a different problem if you have not been told. */}}
{{- with $settings.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $settings.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $settings.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- /* The chart's own per-node constraint plus any the user appended, or
nothing while the group is below two effective replicas. The helper
aborts the render on a collision instead of letting two entries
with the same (topologyKey, whenUnsatisfiable) be merged silently. */}}
{{- $spread := include "stalwart.spreadConstraints" (dict "root" $root "group" $group "settings" $settings "replicas" $replicas) }}
{{- if $spread }}
topologySpreadConstraints:
{{- $spread | nindent 8 }}
{{- end }}
initContainers:
# config.json is copied out of the ConfigMap into the emptyDir rather
# than mounted from it, because STALWART WRITES THIS FILE ITSELF: on