feat(stalwart): spread each node group over nodes and give each one a PDB
Validate Helm Charts / Resource quantities (pull_request) Successful in 20s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 1m20s
Validate Helm Charts / Build stalwart (pull_request) Successful in 21s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 42s
Install-test / Install-test stalwart (pull_request) Successful in 4m31s
Validate Helm Charts / Install-test (pull_request) Successful in 4m55s

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 is contained in:
jklein committed 2026-10-07 11:24:37 +00:00
1 parent 6e196c26a5
commit 5ec60e86b6
14 files changed
+1125 -8

No files matched your search

+125
View File
@@ -354,6 +354,131 @@ accept SMTP behind the SAME Service object.
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
{{- end -}}
{{/*
The labels that identify ONE node group's pods - and the ONLY definition of
them. Input: dict "root" <the root context> "group" <name>. Output: YAML.
Three places consume this: the pod template's labels, the labelSelector of the
chart's own topology spread constraint, and the selector of the group's
PodDisruptionBudget. All three have to agree, and the failure of a disagreement
is silent in both of the latter two:
- a spread constraint whose labelSelector matches no pod is TRIVIALLY
SATISFIED. It distributes nothing, the rollout is green, and nothing is
logged. With whenUnsatisfiable: DoNotSchedule that is worse rather than
better, because the object then looks like a guarantee.
- a PDB whose selector matches no pod reports ALLOWED DISRUPTIONS equal to
its own minAvailable arithmetic over zero pods and never blocks an
eviction, so a drain takes every replica at once - the thing the object was
added to prevent.
One definition is the precedent set by stalwart.env.dbPassword in this file,
for exactly the same reason: a mismatch between two copies is not a render
error. ci/run-selector-match-test.sh is the second half of that - it holds
every rendered selector in this repository against the labels of the pod
templates in the same render, because one definition only helps while nobody
writes a second one.
*/}}
{{- define "stalwart.groupLabels" -}}
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" .root }}
stalwart.jk-dev.de/node-group: {{ .group }}
{{- end -}}
{{/*
The effective scheduling and disruption settings of one node group (#99 shape).
Input: dict "group" <name> "given" <the group's spec from stalwart.nodes>.
Only the two switches the chart interprets carry defaults. affinity,
nodeSelector, tolerations and topologySpreadConstraints are pass-through: the
chart never reads their contents, Kubernetes owns their schema, and a default
would be a value this chart cannot reason about. They therefore arrive here
unchanged, including absent.
*/}}
{{- define "stalwart.groupSettings" -}}
{{- $defaults := dict
"spreadAcrossNodes" true
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s" .group)) -}}
{{- end -}}
{{/*
The topologySpreadConstraints of one node group: the chart's own entry followed
by the user's, or nothing at all.
Input: dict "root" <root context> "group" <name> "settings" <stalwart.groupSettings>
"replicas" <the COMPUTED replica count, as an int>.
WHY THE CHART-OWNED ENTRY NEEDS >= 2 REPLICAS. With one pod the constraint is
satisfied by any placement, so it describes nothing; and the group that ships
with replicas: 0 ("mx") would carry an object about pods that do not exist. The
condition is on the COMPUTED count, so scaleDown: true removes it as well. The
side effect is the one that made this a minor rather than a major change: a
default install (full: 1, mx: 0) renders byte-for-byte what it rendered before.
WHY nodeAffinityPolicy: Ignore IS SET EXPLICITLY, AND IT IS NOT A STYLE CHOICE.
The two node-inclusion policies have OPPOSITE defaults, straight from the API
definition: nodeAffinityPolicy defaults to Honor, nodeTaintsPolicy to Ignore.
Under Honor, only nodes matching the pod's nodeAffinity/nodeSelector count as
domains - so a nodeSelector narrowing a 2-replica group onto ONE node leaves one
eligible domain, both pods land in it, the skew is 0, and the constraint is
satisfied. Both replicas are co-located, nothing is Pending, no event is
emitted, and the selector in the object is correct - so the CI selector check
cannot see it either. With Ignore every node counts, the same narrowing gives
2,0,0, the skew is 2, and the second pod is Pending: the silent half becomes the
loud one. A render abort was the alternative and was rejected, because it would
also have forbidden a nodeSelector spanning five nodes, which is legal and
works.
Note what this field does NOT add: the ceiling on replicas per group comes from
nodeTaintsPolicy being Ignore, and it existed before this value did. values.yaml
argues the two separately for that reason.
WHY A COLLISION ABORTS. Kubernetes keys a spread constraint on
(topologyKey, whenUnsatisfiable), whose API default is DoNotSchedule - so a
user entry on kubernetes.io/hostname without an explicit whenUnsatisfiable
collides with the chart's own. Both ways that collision resolves are worse than
an abort: `kubectl create` fails validation with a message naming a LIST INDEX
instead of a value, and an apply-based path merges the two entries over
patchMergeKey: topologyKey - SILENTLY, dropping one labelSelector and leaving a
green result. The abort names both values instead, and it only fires while the
chart's own entry is actually rendered, since without it there is no duplicate.
*/}}
{{- define "stalwart.spreadConstraints" -}}
{{- $root := .root -}}
{{- $group := .group -}}
{{- $settings := .settings -}}
{{- $own := list -}}
{{- if and $settings.spreadAcrossNodes (ge (int .replicas) 2) -}}
{{- $own = list (dict
"maxSkew" 1
"topologyKey" "kubernetes.io/hostname"
"whenUnsatisfiable" "DoNotSchedule"
"nodeAffinityPolicy" "Ignore"
"labelSelector" (dict "matchLabels" (fromYaml (include "stalwart.groupLabels" (dict "root" $root "group" $group))))) -}}
{{- end -}}
{{- $given := $settings.topologySpreadConstraints -}}
{{- if kindIs "invalid" $given -}}
{{- $given = list -}}
{{- end -}}
{{- if not (kindIs "slice" $given) -}}
{{- fail (printf "nodes.%s.topologySpreadConstraints must be a list of topology spread constraints or null, got %s (%v)" $group (kindOf $given) $given) -}}
{{- end -}}
{{- if $own -}}
{{- range $i, $constraint := $given -}}
{{- if not (kindIs "map" $constraint) -}}
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] must be a mapping, got %s (%v)" $group $i (kindOf $constraint) $constraint) -}}
{{- end -}}
{{- $when := $constraint.whenUnsatisfiable | default "DoNotSchedule" -}}
{{- if and (eq ($constraint.topologyKey | default "") "kubernetes.io/hostname") (eq $when "DoNotSchedule") -}}
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] collides with the constraint nodes.%s.spreadAcrossNodes renders: both are keyed on topologyKey kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule (the API default, so an omitted whenUnsatisfiable counts), and Kubernetes keys a spread constraint on exactly that pair. Two entries sharing it are rejected by `kubectl create` with a message naming a list index rather than a value, and on an apply-based path they are MERGED over patchMergeKey: topologyKey - silently, so one labelSelector is dropped and the result is green. Pick one: set nodes.%s.spreadAcrossNodes: false and keep your entry (it then owns the per-node distribution of this group, and the chart's nodeAffinityPolicy: Ignore is yours to repeat), or drop your entry and keep the chart's. Entries on any OTHER topologyKey - topology.kubernetes.io/zone, for instance - are appended and really do apply as an AND" $group $i $group $group) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $all := concat $own $given -}}
{{- if $all -}}
{{- toYaml $all -}}
{{- end -}}
{{- 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.