feat(stalwart): spread each node group over nodes and give each one a PodDisruptionBudget #97

Merged
jklein merged 1 commits from feat/stalwart-spread-pdb into main 2026-10-07 21:36:04 +02:00
Member

Renders a distribution rule and a PodDisruptionBudget per node group for stalwart, and adds a CI assurance over all charts that every rendered selector really matches the pods it is meant to select.

Fixes #96

What is rendered now

Per node group, both gated on that group's computed replica count being >= 2 (so scaleDown: true removes them):

      topologySpreadConstraints:
        - labelSelector:
            matchLabels:
              stalwart.jk-dev.de/node-group: full
              stalwart.jk-dev.de/release: t-stalwart
          maxSkew: 1
          nodeAffinityPolicy: Ignore
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule

plus <release>-stalwart-<group>-pdb with minAvailable: 1 and the same selector. New per-group values: spreadAcrossNodes, podDisruptionBudget.{enabled,minAvailable}, and a generic pass-through affinity / nodeSelector / tolerations / topologySpreadConstraints. No updateStrategy and no podManagementPolicy — the Kubernetes defaults already do the right thing, as the issue says.

The selector is the chart's, never the operator's. stalwart.groupLabels is the one definition behind the pod template's labels, the spread selector and the PDB selector — precedent in this chart is stalwart.env.dbPassword, for the same reason: a mismatch between two copies is not a render error. A spread constraint whose selector matches no pod is trivially satisfied (a condition over zero pods), so it distributes nothing with a green rollout and no event; a PDB whose selector matches no pod never blocks an eviction while looking like protection.

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 CI check can see it either. With Ignore the same narrowing gives 2,0,0, skew 2, and the second pod is Pending. 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. Verified against the rendered output rather than taken from the reasoning: the constraint carries nodeAffinityPolicy: Ignore and no nodeTaintsPolicy, so the latter is at its API default of Ignore — which is where the replica ceiling comes from, and it predates this change.

A collision aborts the render naming both values. A user entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule or omitted (which is the API default) collides with the chart's own on (topologyKey, whenUnsatisfiable). 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, dropping one labelSelector for a green result. The abort fires only while the chart's own entry is actually rendered.

Minor, and the diff is the evidence

1.0.1+upv0.16.24 → 1.1.0+upv0.16.24. Both objects are gated on >= 2 and the chart ships full: 1 / mx: 0, so a default install renders exactly what it rendered before:

$ diff <(helm template base <origin/main copy> ...) <(helm template base stalwart ...)   # chart defaults
$ diff <(... -f ci/stalwart/test-values.yaml)      <(... -f ci/stalwart/test-values.yaml)

Both empty, byte-for-byte, against origin/main = 6e196c2. One rendered YAML comment in the pod template was deliberately kept as a YAML comment rather than folded into a template comment, because turning it into one was the only thing that made the diff non-empty.

The CI assurance, and what its fail path caught

New job selector-match in validate-charts.yml, running ci/run-selector-match-test.sh over every chart on every PR — the same argument as the quantity render test: the mistake arrives by copy into a chart whose own diff nobody reads for it.

It pulls every PodDisruptionBudget selector and every topologySpreadConstraints labelSelector out of a render and holds it against the labels of the pod templates in that same render — a spread constraint against its own pod template, a PDB against any pod template present. Measured: 30 charts, 5 selectors verified, ~14 s, no cluster.

ok  stalwart: spread[0] on kubernetes.io/hostname at StatefulSet/selector-stalwart-full-sfs.spec.template
    matches its own pod template's labels (…/node-group=full, …/release=selector-stalwart)
ok  stalwart: spread[0] … StatefulSet/selector-stalwart-mx-sfs.spec.template … (…/node-group=mx, …)
ok  stalwart: PodDisruptionBudget selector-stalwart-full-pdb matches 1 pod template(s): StatefulSet/…-full-sfs
ok  stalwart: PodDisruptionBudget selector-stalwart-mx-pdb   matches 1 pod template(s): StatefulSet/…-mx-sfs
ok  keycloak: PodDisruptionBudget selector-keycloak-pdb      matches 1 pod template(s): Deployment/…

Helm is the YAML parser, through the one-template chart ci/yaml2json ({{ .Values | toJson }}): the runners have neither pyyaml nor yq, and a parser written in awk has the one failure mode this repository removes rather than adds — reading nothing, finding no selector, reporting no mismatch, green. The only textual step is the document split on a column-one ---, and getting that wrong does not produce a false pass: the fragment stops being valid YAML and helm exits 1.

The fail path was exercised, not assumed. Replacing the PDB's include with a hand-written second copy and renaming node-group to nodegroup:

FAIL stalwart: PodDisruptionBudget selector-stalwart-full-pdb selects
     stalwart.jk-dev.de/nodegroup=full, stalwart.jk-dev.de/release=selector-stalwart,
     which matches NONE of the 2 pod template(s) in the same render. […]
     Labels actually present on the pod templates: app=…; stalwart.jk-dev.de/node-group=full; …
ERROR: selectors do not match the pod labels they are meant to select in: stalwart   (exit 1)

The same was done for the spread constraint's labelSelector (two FAIL lines, exit 1). Also exercised: the matchExpressions-only path reports "gap" and is not counted as passing (2 selector(s) matched, 2 not checked), and the overlay guard — a chart with a file under ci/selector-match/ that renders no selector fails, probed with a throwaway cyberchef.yaml.

Deliberately out of scope and named so the gap is not mistaken for coverage: podAffinity / podAntiAffinity (a term matching nothing is a legitimate configuration there), and set-based matchExpressions.

What is NOT measured, and will not be by this PR

The acceptance list of #96 asks for render statements only, and every one of them is demonstrated above. These are not:

  • The actual scheduling effect. That two full pods really land on two different nodes, and that the third would go Pending on a three-worker cluster, is reasoning from the API semantics plus the rendered object — no cluster was involved. The four skew cases from the decision comment were re-checked against the rendered constraint, not re-derived.
  • PDB behaviour under kubectl drain. That the budget actually holds one pod of a group during a node drain is untested here.
  • The replica ceiling from nodeTaintsPolicy. "As many replicas per group as there are schedulable workers" follows from control-plane domains holding the minimum at 0 with maxSkew: 1; it was not observed on a cluster.
  • nodeAffinityPolicy being pruned on an old API server. Documented as the pruning behaviour for a field behind a disabled gate, explicitly flagged in values.yaml and NOTES.txt as not measured against a pre-1.26 server.
  • The install-test does not cover the new objects at all. ci/stalwart/test-values.yaml runs one replica in full and zero in mx, deliberately (a second pod needs a real in-memory store, and a running mx pod would fail-open into a second full node against an empty database). So the install-test renders neither the PDB nor the spread constraint. That is exactly why ci/selector-match/stalwart.yaml exists as a render-only overlay, and why a missing-selector result with an overlay present is a failure rather than a shrug.
  • No k3d, no Docker/Podman and no Trivy were available in this environment; helm lint --strict, both render tests, the schema in both directions and every fail path were run locally.

Also in this PR

  • README.md: a new "Selector-match render test" section, and three bullets in §11 (PDB per group, the distribution-rule convention with its three traps, and the CI assurance). ci/helm-dependencies.sh now runs in six places, not five.
  • Wiki: stalwart (values + operating notes), CI-CD (the new job), Chart-Style-Guide (the convention), Lessons-Learned (the asymmetric policy defaults, and the silent merge over patchMergeKey).
  • NOTES.txt prints, per group, which case applied — spread enforced / not rendered / off, PDB rendered with its minAvailable / not rendered and why — plus the version caveat and the ceiling, once, when any group has the constraint.
Renders a distribution rule and a `PodDisruptionBudget` **per node group** for `stalwart`, and adds a CI assurance over **all** charts that every rendered selector really matches the pods it is meant to select. Fixes #96 ## What is rendered now Per node group, both gated on that group's **computed** replica count being `>= 2` (so `scaleDown: true` removes them): ```yaml topologySpreadConstraints: - labelSelector: matchLabels: stalwart.jk-dev.de/node-group: full stalwart.jk-dev.de/release: t-stalwart maxSkew: 1 nodeAffinityPolicy: Ignore topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule ``` plus `<release>-stalwart-<group>-pdb` with `minAvailable: 1` and the same selector. New per-group values: `spreadAcrossNodes`, `podDisruptionBudget.{enabled,minAvailable}`, and a generic pass-through `affinity` / `nodeSelector` / `tolerations` / `topologySpreadConstraints`. No `updateStrategy` and no `podManagementPolicy` — the Kubernetes defaults already do the right thing, as the issue says. **The selector is the chart's, never the operator's.** `stalwart.groupLabels` is the one definition behind the pod template's labels, the spread selector and the PDB selector — precedent in this chart is `stalwart.env.dbPassword`, for the same reason: a mismatch between two copies is not a render error. A spread constraint whose selector matches no pod is *trivially satisfied* (a condition over zero pods), so it distributes nothing with a green rollout and no event; a PDB whose selector matches no pod never blocks an eviction while looking like protection. **`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 CI check can see it either. With `Ignore` the same narrowing gives `2,0,0`, skew 2, and the second pod is `Pending`. 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. Verified against the rendered output rather than taken from the reasoning: the constraint carries `nodeAffinityPolicy: Ignore` and **no** `nodeTaintsPolicy`, so the latter is at its API default of `Ignore` — which is where the replica ceiling comes from, and it predates this change. **A collision aborts the render naming both values.** A user entry on `kubernetes.io/hostname` with `whenUnsatisfiable` `DoNotSchedule` *or omitted* (which is the API default) collides with the chart's own on `(topologyKey, whenUnsatisfiable)`. `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**, dropping one `labelSelector` for a green result. The abort fires only while the chart's own entry is actually rendered. ## Minor, and the diff is the evidence `1.0.1+upv0.16.24` → **`1.1.0+upv0.16.24`**. Both objects are gated on `>= 2` and the chart ships `full: 1` / `mx: 0`, so a default install renders exactly what it rendered before: ``` $ diff <(helm template base <origin/main copy> ...) <(helm template base stalwart ...) # chart defaults $ diff <(... -f ci/stalwart/test-values.yaml) <(... -f ci/stalwart/test-values.yaml) ``` Both **empty**, byte-for-byte, against `origin/main` = `6e196c2`. One rendered YAML comment in the pod template was deliberately kept as a YAML comment rather than folded into a template comment, because turning it into one was the *only* thing that made the diff non-empty. ## The CI assurance, and what its fail path caught New job `selector-match` in `validate-charts.yml`, running `ci/run-selector-match-test.sh` over **every** chart on every PR — the same argument as the quantity render test: the mistake arrives by copy into a chart whose own diff nobody reads for it. It pulls every `PodDisruptionBudget` selector and every `topologySpreadConstraints` `labelSelector` out of a render and holds it against the labels of the pod templates **in that same render** — a spread constraint against *its own* pod template, a PDB against any pod template present. Measured: **30 charts, 5 selectors verified, ~14 s**, no cluster. ``` ok stalwart: spread[0] on kubernetes.io/hostname at StatefulSet/selector-stalwart-full-sfs.spec.template matches its own pod template's labels (…/node-group=full, …/release=selector-stalwart) ok stalwart: spread[0] … StatefulSet/selector-stalwart-mx-sfs.spec.template … (…/node-group=mx, …) ok stalwart: PodDisruptionBudget selector-stalwart-full-pdb matches 1 pod template(s): StatefulSet/…-full-sfs ok stalwart: PodDisruptionBudget selector-stalwart-mx-pdb matches 1 pod template(s): StatefulSet/…-mx-sfs ok keycloak: PodDisruptionBudget selector-keycloak-pdb matches 1 pod template(s): Deployment/… ``` **Helm is the YAML parser**, through the one-template chart `ci/yaml2json` (`{{ .Values | toJson }}`): the runners have neither `pyyaml` nor `yq`, and a parser written in `awk` has the one failure mode this repository removes rather than adds — reading nothing, finding no selector, reporting no mismatch, green. The only textual step is the document split on a column-one `---`, and getting that wrong does not produce a false pass: the fragment stops being valid YAML and `helm` exits 1. **The fail path was exercised, not assumed.** Replacing the PDB's `include` with a hand-written second copy and renaming `node-group` to `nodegroup`: ``` FAIL stalwart: PodDisruptionBudget selector-stalwart-full-pdb selects stalwart.jk-dev.de/nodegroup=full, stalwart.jk-dev.de/release=selector-stalwart, which matches NONE of the 2 pod template(s) in the same render. […] Labels actually present on the pod templates: app=…; stalwart.jk-dev.de/node-group=full; … ERROR: selectors do not match the pod labels they are meant to select in: stalwart (exit 1) ``` The same was done for the spread constraint's `labelSelector` (two `FAIL` lines, exit 1). Also exercised: the `matchExpressions`-only path reports **"gap"** and is not counted as passing (`2 selector(s) matched, 2 not checked`), and the overlay guard — a chart with a file under `ci/selector-match/` that renders no selector **fails**, probed with a throwaway `cyberchef.yaml`. Deliberately out of scope and named so the gap is not mistaken for coverage: `podAffinity` / `podAntiAffinity` (a term matching nothing is a legitimate configuration there), and set-based `matchExpressions`. ## What is NOT measured, and will not be by this PR The acceptance list of #96 asks for render statements only, and every one of them is demonstrated above. These are not: - **The actual scheduling effect.** That two `full` pods really land on two different nodes, and that the third would go `Pending` on a three-worker cluster, is reasoning from the API semantics plus the rendered object — no cluster was involved. The four skew cases from the decision comment were re-checked against the *rendered* constraint, not re-derived. - **PDB behaviour under `kubectl drain`.** That the budget actually holds one pod of a group during a node drain is untested here. - **The replica ceiling from `nodeTaintsPolicy`.** "As many replicas per group as there are schedulable workers" follows from control-plane domains holding the minimum at 0 with `maxSkew: 1`; it was not observed on a cluster. - **`nodeAffinityPolicy` being pruned on an old API server.** Documented as the pruning behaviour for a field behind a disabled gate, explicitly flagged in `values.yaml` and `NOTES.txt` as **not measured** against a pre-1.26 server. - **The install-test does not cover the new objects at all.** `ci/stalwart/test-values.yaml` runs one replica in `full` and zero in `mx`, deliberately (a second pod needs a real in-memory store, and a running `mx` pod would fail-open into a second full node against an empty database). So the install-test renders **neither** the PDB **nor** the spread constraint. That is exactly why `ci/selector-match/stalwart.yaml` exists as a render-only overlay, and why a missing-selector result with an overlay present is a failure rather than a shrug. - No k3d, no Docker/Podman and no Trivy were available in this environment; `helm lint --strict`, both render tests, the schema in both directions and every fail path were run locally. ## Also in this PR - `README.md`: a new "Selector-match render test" section, and three bullets in §11 (PDB per group, the distribution-rule convention with its three traps, and the CI assurance). `ci/helm-dependencies.sh` now runs in **six** places, not five. - Wiki: `stalwart` (values + operating notes), `CI-CD` (the new job), `Chart-Style-Guide` (the convention), `Lessons-Learned` (the asymmetric policy defaults, and the silent merge over `patchMergeKey`). - `NOTES.txt` prints, per group, which case applied — spread enforced / not rendered / off, PDB rendered with its `minAvailable` / not rendered and why — plus the version caveat and the ceiling, once, when any group has the constraint.
jlab-bot added 1 commit 2026-10-07 13:33:59 +02:00
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
5ec60e86b6
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
jklein approved these changes 2026-10-07 21:36:00 +02:00
jklein merged commit 6ade74db80 into main 2026-10-07 21:36:04 +02:00
jklein deleted branch feat/stalwart-spread-pdb 2026-10-07 21:36:05 +02:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jlab-cluster/helm-charts#97