Files
helm-charts/stalwart/templates/stalwart.pdb.yaml
T
jklein 5ec60e86b6
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
feat(stalwart): spread each node group over nodes and give each one a PDB
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
2026-10-07 11:24:37 +00:00

63 lines
3.3 KiB
YAML

{{- /*
ONE PodDisruptionBudget PER NODE GROUP, and that is the whole difference from
the precedent. keycloak.pdb.yaml (Gitea #83, README guideline 11) set the
convention this file follows; what it did not have to deal with is a chart whose
replica count is a property of a GROUP rather than of the release. So the
condition is evaluated per group, and with full: 2 / mx: 1 exactly one object is
rendered.
WHY IT EXISTS: with two replicas in a group a node drain is otherwise free to
take both at once - and a drain is the most common reason a pod moves, because
it is what a node upgrade does. Without this object the second replica buys
nothing against the failure it was added for. The chart's topology spread
constraint and this budget are two halves of one answer: the constraint decides
that the two pods sit on different nodes, the budget decides that only one of
those nodes may be emptied at a time.
minAvailable rather than maxUnavailable, as in keycloak: the promise wanted is
about the service - at least one node of this group keeps answering throughout
the drain - and not about the size of the disruption.
AND WHY IT IS NOT RENDERED BELOW TWO EFFECTIVE REPLICAS. This is the trap that
makes a PDB worth a condition rather than an unconditional object, quoted from
the precedent because it has not changed: minAvailable: 1 with exactly ONE
replica forbids every voluntary eviction, so `kubectl drain` blocks forever on a
pod nothing is allowed to move - and the message then names the PDB and not the
replica count, which sends the reader to the wrong file. The same holds for
scaleDown: true, where the group has no pods at all and the object would only
describe a promise about nothing.
The count is therefore the COMPUTED one (stalwart.replicas), not the raw
nodes.<group>.replicas, which is what makes scaleDown: true remove the budget
rather than leave it behind. NOTES.txt prints, per group, which of the cases
applied, so an operator does not have to infer it from `kubectl get pdb`.
THE SELECTOR COMES FROM stalwart.groupLabels, the same definition the pod
template's labels come from. A PDB whose selector matches no pod does not fail:
it reports its arithmetic over zero pods, never blocks an eviction, and a drain
takes the whole group while the object sits there looking like protection.
ci/run-selector-match-test.sh holds this selector against the rendered pod
templates on every pull request for exactly that reason.
*/ -}}
{{- $root := . -}}
{{- 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 and $settings.podDisruptionBudget.enabled (ge $replicas 2) }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $name }}-pdb
labels:
app: {{ $name }}-sfs
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 4 }}
spec:
minAvailable: {{ $settings.podDisruptionBudget.minAvailable | int64 }}
selector:
matchLabels:
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 6 }}
{{- end }}
{{- end }}