Renders a distribution rule and a PodDisruptionBudgetper 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.
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 nonodeTaintsPolicy, 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 whenUnsatisfiableDoNotScheduleor 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: topologyKeysilently, 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:
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 topologySpreadConstraintslabelSelector 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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Renders a distribution rule and a
PodDisruptionBudgetper node group forstalwart, 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(soscaleDown: trueremoves them):plus
<release>-stalwart-<group>-pdbwithminAvailable: 1and the same selector. New per-group values:spreadAcrossNodes,podDisruptionBudget.{enabled,minAvailable}, and a generic pass-throughaffinity/nodeSelector/tolerations/topologySpreadConstraints. NoupdateStrategyand nopodManagementPolicy— the Kubernetes defaults already do the right thing, as the issue says.The selector is the chart's, never the operator's.
stalwart.groupLabelsis the one definition behind the pod template's labels, the spread selector and the PDB selector — precedent in this chart isstalwart.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: Ignoreis set explicitly, and that is the load-bearing detail. Its API default isHonor, under which a narrowingnodeSelectorshrinks the set of counted domains until both replicas fit into one: skew 0, both pods co-located, noPending, no event, and a selector that is correct — so no CI check can see it either. WithIgnorethe same narrowing gives2,0,0, skew 2, and the second pod isPending. A render abort was the alternative and was rejected because it would also have forbidden anodeSelectorspanning five nodes, which is legal and works. Verified against the rendered output rather than taken from the reasoning: the constraint carriesnodeAffinityPolicy: Ignoreand nonodeTaintsPolicy, so the latter is at its API default ofIgnore— 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/hostnamewithwhenUnsatisfiableDoNotScheduleor omitted (which is the API default) collides with the chart's own on(topologyKey, whenUnsatisfiable).kubectl createrejects the duplicate with a message about a list index instead of a value, and an apply-based path merges the two overpatchMergeKey: topologyKeysilently, dropping onelabelSelectorfor 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>= 2and the chart shipsfull: 1/mx: 0, so a default install renders exactly what it rendered before: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-matchinvalidate-charts.yml, runningci/run-selector-match-test.shover 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
PodDisruptionBudgetselector and everytopologySpreadConstraintslabelSelectorout 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.Helm is the YAML parser, through the one-template chart
ci/yaml2json({{ .Values | toJson }}): the runners have neitherpyyamlnoryq, and a parser written inawkhas 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 andhelmexits 1.The fail path was exercised, not assumed. Replacing the PDB's
includewith a hand-written second copy and renamingnode-grouptonodegroup:The same was done for the spread constraint's
labelSelector(twoFAILlines, exit 1). Also exercised: thematchExpressions-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 underci/selector-match/that renders no selector fails, probed with a throwawaycyberchef.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-basedmatchExpressions.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:
fullpods really land on two different nodes, and that the third would goPendingon 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.kubectl drain. That the budget actually holds one pod of a group during a node drain is untested here.nodeTaintsPolicy. "As many replicas per group as there are schedulable workers" follows from control-plane domains holding the minimum at 0 withmaxSkew: 1; it was not observed on a cluster.nodeAffinityPolicybeing pruned on an old API server. Documented as the pruning behaviour for a field behind a disabled gate, explicitly flagged invalues.yamlandNOTES.txtas not measured against a pre-1.26 server.ci/stalwart/test-values.yamlruns one replica infulland zero inmx, deliberately (a second pod needs a real in-memory store, and a runningmxpod 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 whyci/selector-match/stalwart.yamlexists as a render-only overlay, and why a missing-selector result with an overlay present is a failure rather than a shrug.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.shnow runs in six places, not five.stalwart(values + operating notes),CI-CD(the new job),Chart-Style-Guide(the convention),Lessons-Learned(the asymmetric policy defaults, and the silent merge overpatchMergeKey).NOTES.txtprints, per group, which case applied — spread enforced / not rendered / off, PDB rendered with itsminAvailable/ not rendered and why — plus the version caveat and the ceiling, once, when any group has the constraint.