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

+150
View File
@@ -300,6 +300,137 @@ nodes:
# Every group needs http, including mx: it is the port all three probes
# use. It is not published for mx.
exposedPorts: [smtp, submissions, imaps, http]
# -----------------------------------------------------------------------
# Distribute THIS GROUP's pods over distinct nodes (Gitea #96)
# -----------------------------------------------------------------------
# Renders a topologySpreadConstraints entry on topologyKey
# kubernetes.io/hostname with maxSkew: 1 and a labelSelector the CHART
# fills in — the two labels it puts on its own pods anyway. You never
# write that selector, and that is the point: a spread constraint whose
# selector matches no pod is TRIVIALLY SATISFIED. It distributes nothing,
# the rollout is green, and nothing anywhere says so.
#
# ONLY WITHIN THIS GROUP. The selector names the release AND the node
# group, so "full" pods are spread against "full" pods and "mx" pods
# against "mx" pods. A "full" and an "mx" pod on the same node is
# deliberately fine — they are different roles, not redundant copies.
#
# RENDERED ONLY FROM TWO EFFECTIVE REPLICAS UPWARDS. With one pod any
# placement satisfies it, so it would describe nothing; the count used is
# the computed one, so scaleDown: true removes it too.
#
# whenUnsatisfiable IS DoNotSchedule, i.e. THIS IS A PROMISE AND NOT AN
# EFFORT — and the price is a pod that stays Pending rather than one that
# lands somewhere useless. ScheduleAnyway was the original proposal and was
# rejected: with one worker at 97 % requested CPU the scheduler may put both
# replicas of a group on the same remaining node, entirely within the rule
# and with the redundancy gone. The cases where the promise costs something:
#
# - TWO OF THREE WORKERS GONE: the second replica stays Pending instead
# of joining the survivor — but only WHILE THE Node OBJECTS STILL
# EXIST. A NotReady node still counts as an (empty) domain, so the
# third replica would raise the skew to 2. Delete the Node objects and
# the domains disappear with them and the pod schedules. Same
# mechanism, opposite outcome, one step apart.
# - A NARROWING nodeSelector/affinity BELOW: see nodeSelector further
# down. That combination blocks itself, deliberately and loudly.
#
# A Pending pod with no scheduling event reads like a different problem
# than a distribution rule, so NOTES.txt names this rule whenever it is
# rendered.
#
# THE FIELD THAT CAN BE DROPPED WITHOUT A WORD: the constraint carries
# nodeAffinityPolicy: Ignore, and that field is BETA — on by default since
# Kubernetes 1.26, graduated to GA with the feature gate
# NodeInclusionPolicyInPodTopologySpread in 1.33. On an API server older
# than 1.26, or one with the gate switched off, the field is pruned
# SILENTLY, and the gap it was set against comes straight back (the
# nodeSelector case below). Nothing reports it: the object still looks
# correct in `kubectl get sts -o yaml`, minus one line. So 1.26 is the
# minimum version this chart can make the promise on at all. NOT MEASURED
# against an older server — this is the documented pruning behaviour for a
# field behind a disabled gate, not something tried here. Check the
# rendered StatefulSet against the live one if the server is near that
# boundary.
#
# WHAT IS *NOT* A CONSEQUENCE OF THAT FIELD, because the two
# node-inclusion policies are not a package: the ceiling on replicas
# described at nodeSelector below comes from nodeTaintsPolicy, which this
# chart leaves at its own default. It was there before nodeAffinityPolicy
# was set and is not its price.
#
# ONE PRECONDITION, AND IT IS NOT IN THIS FILE: this works because the
# StatefulSet has NO volumeClaimTemplates. Open measurement 1 on #65 asks
# whether anything under /var/lib/stalwart has to survive a restart; if
# that answer ever becomes yes, a PVC per group appears, and with a
# node-local or topology-bound storage class the pod is pinned to wherever
# its volume was first provisioned. Distribution is then decided at
# provisioning time rather than by this rule, and DoNotSchedule can keep a
# returning pod Pending for good. This rule holds only while the chart
# stays PVC-free.
#
# AND THE COST OF TURNING IT ON BY SCALING UP: going from 1 to 2 replicas
# changes the POD TEMPLATE (the constraint appears in it), so the existing
# pod is replaced in the same rollout round rather than merely joined by a
# second one. Expect a restart of the running node, not just an addition.
spreadAcrossNodes: true
# A PodDisruptionBudget for this group, rendered only while the group's
# COMPUTED replica count is >= 2 — the spread constraint's companion: that
# one decides the two pods sit on different nodes, this one decides only
# one of those nodes may be emptied at a time.
#
# The condition is not tidiness. minAvailable: 1 with exactly one replica
# forbids every voluntary eviction, so `kubectl drain` blocks forever on a
# pod nothing may move — and the message names the PDB rather than the
# replica count, which sends the reader to the wrong file. Precedent and
# full argument: keycloak (README, guideline 11).
podDisruptionBudget:
enabled: true
# minAvailable, not maxUnavailable: the promise is about the service —
# at least one pod of this group keeps answering through a drain.
minAvailable: 1
# Generic pass-through into this group's pod spec. Rendered verbatim with
# toYaml; the chart reads none of their contents, which is why they are
# loosely typed in values.schema.json — Kubernetes owns their schema. An
# empty value renders no field at all.
#
# THE ONE COMBINATION THAT BLOCKS ITSELF, and it does so on purpose:
# narrowing the eligible nodes below this group's replica count while
# spreadAcrossNodes is on leaves a replica permanently Pending. With
# nodeAffinityPolicy: Ignore on the chart's constraint, a nodeSelector
# down to one node gives 2,0,0 — skew 2 — rather than the one eligible
# domain and skew 0 it would give under the API default of Honor. The
# second shape is what we do NOT want: both pods co-located, nothing
# Pending, no event, and a selector that is perfectly correct, so no check
# in CI can see it either. Loud beats silent; that is the whole trade.
#
# AND THE CEILING, WHICH IS A RULE AND NOT A NUMBER. The chart leaves
# nodeTaintsPolicy at its API default of Ignore, so EVERY node counts as a
# domain — control-plane nodes included, and no Stalwart pod ever runs
# there. Those domains hold the minimum at 0 permanently, so with
# maxSkew: 1 a group can never place more replicas than there are
# SCHEDULABLE WORKERS. Add a worker and the ceiling rises with it; remove
# one and it falls. Do not write today's count down here — it would be
# wrong at the next cluster rebuild and nobody would come back for it.
# Raising a group past that ceiling needs spreadAcrossNodes: false, or an
# own topologySpreadConstraints entry with ScheduleAnyway.
affinity: {}
nodeSelector: {}
tolerations: []
# Appended AFTER the chart's own constraint and really AND-ed with it, so
# a second key — topology.kubernetes.io/zone, say — works as expected.
#
# An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule
# (or omitted, which IS DoNotSchedule) ABORTS THE RENDER naming both this
# value and spreadAcrossNodes. Kubernetes keys a constraint on exactly
# that pair: `kubectl create` rejects the duplicate with a message about a
# list index instead of a value, and an apply-based path merges the two
# over patchMergeKey: topologyKey SILENTLY — one labelSelector is dropped
# and the result is green. A named abort is better than either.
topologySpreadConstraints: []
resources:
requests:
cpu: 200m
@@ -324,6 +455,25 @@ nodes:
# fail-open above), which is the opposite of what this group is for.
replicas: 0
exposedPorts: [smtp, http]
# Same six keys as nodes.full, same defaults, and argued in full there —
# including the three things that are easy to get wrong: that
# nodeAffinityPolicy can be pruned silently below Kubernetes 1.26, that the
# replica ceiling is "as many as there are schedulable workers" rather than
# a number, and that the ceiling comes from nodeTaintsPolicy and not from
# the field this chart sets.
#
# Neither object is rendered for this group while replicas is 0, and the
# condition is on the computed count: raising it to 2 (after the "mx"
# ClusterRole exists — see above) brings both the spread constraint and the
# PodDisruptionBudget with it, independently of what "full" does.
spreadAcrossNodes: true
podDisruptionBudget:
enabled: true
minAvailable: 1
affinity: {}
nodeSelector: {}
tolerations: []
topologySpreadConstraints: []
resources:
requests:
cpu: 100m