feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes #126

Merged
jklein merged 4 commits from feat/stalwart-mta-sts-routes into main 2026-10-13 13:19:39 +02:00
Member

Gitea #123: stalwart's MTA-STS policy route moves out of jk-cluster-config and into the chart as a maintainable list, rendering in both routing modes. mtaSts.hosts: [] is the default, so this changes nothing about the running installation until it is filled.

Minor, additive, empty default — 1.2.0+upv0.16.25 → 1.3.0+upv0.16.25. helm template against origin/main is byte-identical for the defaults and for ci/stalwart/test-values.yaml as it stands on origin/main.


Where I could not take the approved plan as written, and this is the part to review

Points 2, 4 and 9 of the issue are not jointly satisfiable, and the cause is cert-manager. The plan asks for one Ingress with one rule per host (4), a per-entry optional clusterIssuer where empty means no annotation (2), and an overlay carrying one entry with an issuer and one without (9). Those three cannot all hold:

  • cert-manager.io/cluster-issuer is an annotation — object-scoped. One Ingress carries exactly one, or none, and with it absent the shim creates nothing for that object — for every tls entry on it, not for some.
  • secretName is tls-entry-scoped, and the shim creates one Certificate per entry.

So one object cannot both request a certificate from issuer X and leave another host's secret alone, which is exactly what point 2's empty issuer has to mean. Requirement 9 therefore needs at least two objects.

And a second rule came out of measuring rather than reasoning. kubectl -n stalwart get certificates, 2026-10-13:

Certificate tls-mta-sts-bekemeyer-klein-de-ingress
  secretName:      tls-mta-sts-bekemeyer-klein-de-ingress
  ownerReferences: Ingress/mta-sts-ingress
Certificate tls-stalwart-stalwart-ingress
  secretName:      tls-stalwart-stalwart-ingress
  ownerReferences: Ingress/stalwart-stalwart-ingress

ingress-shim names the Certificate after the secret and owns it from the Ingress. Two objects whose tls entries name one secret therefore drive the shim at one Certificate name from two owners.

What I implemented instead: one Ingress per distinct clusterIssuer, one rule per host inside it, tls: grouped by secret inside it, and a render abort when one secret appears under two issuers. Point 4's "one rule per host" and "tls grouped by secret" hold literally; only the object count splits, and only on the field that must. The grouping is rss-bridge.tlsGroups adopted rather than reinvented, as point 4 asks — what is new is the issuer dimension, which that chart does not have (it sets one chart-wide issuer on its single object). Say so if you would rather have the restriction "all entries must agree on one issuer" and a single object; that is the only other coherent shape and it would make requirement 9 impossible.

Object names: by index — <release>-stalwart-mta-sts-ingress, -1, … in this repo's idiom ({{ if $i }}-{{ $i }}{{ end }}), as homeassistant's routes and keycloak's realm Ingresses do it. This reverses an earlier draft of this PR, and the reversal is the interesting part.

I had named them by issuer, reading the style guide's index rule as resting on "acceptable only because a route holds no state and no certificate" and observing that these objects hold one. That reading was right — and the rule's stated reason was wrong. The parallel #122 round named by index, cited the same rule as binding, and entered keycloak into it, whose realm Ingresses own certificates too. So two charts did opposite things for one shape, and the rule had been extended onto exactly the cases its own precondition excluded. #125 is merged and published, so the reason was repaired and the naming unified on index; keycloak was not touched.

What actually carries the index rule, in three steps with deliberately different standing:

  1. Measured (by the maintainer, in jk-cluster): cert-manager runs without --enable-certificate-owner-ref — absent from the Deployment's args and from the ControllerConfiguration its --config names (which carries only kind: and enableGatewayAPI: true). The controller default applies.
  2. Documented behaviour of that default, not measured here: the Certificate is owner-referenced from the Ingress and collected with it, but the Secret is not — cert-manager owner-references a Secret only with that flag on. A rename therefore recreates the Certificate object against a surviving Secret and issues nothing: no ACME request, no rate limit touched.
  3. Not applied: no rename was performed against a cluster, on either round. Steps 1–2 are the mechanism, not a trial of it.

And the promise hangs on a cluster-level flag this repository does not control. With it switched on, a reorder would cost a fresh issuance per renumbered object — worth checking before anyone enables it, and not something a chart can defend against.

Unchanged by the rename: the structure. One Ingress per distinct clusterIssuer, one rule per host, tls grouped by secret, and the abort when a secret spans two issuers. Grouping is a cert-manager question, naming is a style question, and they are independent.

Two obligations the rename creates, both discharged:

  • NOTES.txt prints the mapping — index, object name, issuer, secrets and hosts per object, the way keycloak's does for its realms — because the name no longer carries the issuer and nothing else in the cluster says which object holds which. Visible in the install-test log on this head: mta-sts-ingress with cluster issuer: "ci-selfsigned", mta-sts-ingress-1 with cluster issuer: NONE.
  • The reordering hazard is documented as sharper than homeassistant's. There an index names a host. Here it names an issuer group derived from the list, so moving one entry can change the grouping and renumber objects that were not themselves moved.

An asymmetry with keycloak, named in the template header: there, both modes render one object per entry, so the index question arises twice. Here it arises on the Ingress side only — Gateway mode renders a single HTTPRoute carrying every hostname, TLS being none of a route's business, so there is no index and no name to go stale.

Version stays 1.3.0+upv0.16.25: nothing is published yet, so nothing in a release is being renamed.

Where I reproduced the briefing exactly

Every live measurement in the task held, with nothing to correct:

Claim Reproduced
two Ingress objects in namespace stalwart, one from release jk-cluster-config-fleet-cd-stalwart-mta-sts yes
host mta-sts.bekemeyer-klein.de, one path /.well-known/mta-sts.txt, pathType: Exact yes
backend stalwart-stalwart-full-http-service, port name http yes — and the Service's port number is 8080, matching ports.http.containerPort, so the route's backendRefs number is read from the template and not guessed
TLS secret tls-mta-sts-bekemeyer-klein-de-ingress, cluster-issuer: letsencrypt-issuer, no nginx annotations yes (the only other annotations are Rancher/Fleet's field.cattle.io and objectset.rio.cattle.io, controller-added)
the chart knows MTA-STS nowhere — git grep -i 'mta-sts|well-known' over stalwart/ on origin/main yes, zero hits
the live policy: version: STSv1, mode: testing, max_age: 604800, mx: mail.bekemeyer-klein.de yes, curl against the live host
Chart.yaml at 1.2.0+upv0.16.25 / appVersion: "v0.16.25" yes

The two things the issue body did not say explicitly

1. The new objects hang on the mode switch correctly, and no required value does nothing. Executed rather than assumed:

Configuration Result
ingress.enabled: true, list set 3 Ingress objects (main + 2 MTA-STS), 0 HTTPRoute
ingress.enabled: false, httpRoute.enabled: true, list set 2 HTTPRoute (WebUI + MTA-STS), 0 Ingress, mail Certificate still rendered
both false, list set 0 of either, mail Certificate still rendered, and NOTES.txt says the list renders nothing and why
Gateway mode, entry with no tlsSecretName and no clusterIssuer renders — the required() is on the Ingress path only, so neither mode demands a value it ignores
Ingress mode, entry with no tlsSecretName aborts, naming the entry index, the host, and why there is no default

ingress.domain is untouched by all of this: it stays "REQUIRED either way" for the mail certificate's dnsNames, and the MTA-STS list is independent of it — the hosts are their own values, so nothing new hangs off it.

2. Nothing outside the routing templates reads ingress.enabled in this chart. Screened over stalwart/templates/ and stalwart/values.schema.json: the only readers are stalwart.ingress.yaml, stalwart.httproute.yaml (its mutual-exclusion guard) and NOTES.txt, which already goes through $published = ingress.enabled OR httpRoute.enabled from group 3. No defect of the freesailarr / ytdlp-web-player class here — measured, not assumed. stalwart.certificate.yaml reads ingress.domain and ingress.clusterIssuer, both with their own required(), which is correct and unchanged.

A defect found after opening this PR, and found because the parallel round wrote its lesson down

Worth reading as a result about the process as much as about the chart. While pushing the wiki I had to rebase onto the keycloak round's (#122) Lessons-Learned entry, read it, and recognised my own code in it: a specific validation must not be reachable in a state that a structural validation already rejects.

Rendered the combination it names, against my own branch — and it reproduced:

# both modes on AND an entry missing its tlsSecretName
Error: ... mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName,
       and in Ingress mode every entry needs one: ...

True, and the wrong diagnosis: the values file's defect is that ingress.enabled and httpRoute.enabled are both true, and "in Ingress mode" is a claim about a configuration that is not coherent at all. Whoever met it would add a tlsSecretName, re-run, and reach the real message one round later — or conclude this feature was the problem.

Fixed in 9f96204 by scope, not by ordering (Helm renders templates in an order a chart cannot pin, so this template may not assume the httproute template's abort runs first): the Ingress-mode MTA-STS block is gated on ingress.enabled and not httpRoute.enabled, so in the incoherent combination it validates nothing and the only fail left is the structural one. Re-measured after the fix:

Configuration Message
both modes on, tlsSecretName missing the mode-conflict abort
both modes on, entry complete the mode-conflict abort
Ingress mode alone, tlsSecretName missing the per-entry abort — so the guard is not dead

All three empty render diffs and the assertion figure were re-measured after the fix and were unchanged by it (it was 58/21 at that point; see the rebase history below for why that number moved twice afterwards for reasons that have nothing to do with this fix). Two independent charts met this in the same round, from two agents, neither having seen the other's code — so I added that second occurrence to their Lessons-Learned entry rather than writing a competing one, since one chart's story and two charts' is the difference between an anecdote and a rule.

And the #-comment trap was avoided. Every explanatory comment in the new template blocks is inside {{/* */}} or {{- /* */}}. Confirmed twice: by the empty render diffs, and by grepping the rendered MTA-STS objects in both modes for stray # lines — 0 in each.

Verification

  • ci/helm-dependencies.sh stalwart first (valkey), or every helm command exits 1.
  • helm lint --strict three ways — defaults, CI values, CI values + Gateway overlay: 1 chart(s) linted, 0 chart(s) failed each. helm package → stalwart-1.3.0+upv0.16.25.tgz.
  • Empty helm template diff against origin/main, twice: the defaults (14 objects, identical) and ci/stalwart/test-values.yaml as it stands on origin/main, plus a third for the Gateway overlay as it stands there. With the new fixture the diff is non-empty by construction — that is the feature, and it adds 2 Ingress objects.
  • Non-empty render inspected in both modes with a four-entry list (with issuer / subdomain / sharing entry 1's secret / no issuer): Ingress mode gives 2 MTA-STS objects — the letsencrypt-issuer one carrying 3 rules and 2 tls entries, with entries 1 and 3 collapsed under one secret as 2 SANs, and the issuer-less one carrying 1 rule and no annotation. Gateway mode gives 1 route, 4 hostnames, type: Exact, port: 8080.
  • ci/run-httproute-match-test.sh: 64 assertions held over 22 charts, 0 failed, read from CI on the final head (runs/814/jobs/2740) — unchanged by the rename, which was expected but measured rather than assumed: object names are not assertions. stalwart 2 → 4, both routes' parentRefs and both backendRefs resolving to httproute-stalwart-full-http-service:8080 in the same render. ci/run-quantity-render-test.sh (28 exercised, 0 failed) and ci/run-selector-match-test.sh (5 selectors, 0 failed) both clean.
  • Schema in both directions: every overlay and fixture accepted; mtaSts.hosts[0].bogus rejected (additional properties 'bogus' not allowed); an entry without host rejected (missing property 'host') — caught by the schema before the template; and mtaSts.enabled rejected, so "the list is the switch" is enforced rather than merely documented.
  • Fail paths executed, not assumed: incomplete entry (no host), missing tlsSecretName in Ingress mode, both modes at once with the list set (the existing abort fires, naming both value paths), a duplicate host — which aborts in both modes, from each template, since the guard sits in the shared helper — and one secret under two issuers. Each printed its full message and exited non-zero.
  • NOTES.txt reviewed by reading, in all three branches (Ingress / Gateway / neither), rendered through a scratch chart copy because helm template does not emit notes — and helm install --dry-run cannot be used for the Gateway branch here, since this workspace's identity is forbidden httproutes and helm looks the object up. One layout defect found that way and fixed: the → node group line sat after a two-lines-per-entry host list and read as part of the last entry; it now precedes the list.

What stays unproven, by construction

  • That traffic flows. No Gateway controller runs in CI and none will, so no HTTPRoute ever reaches Accepted: True. That this repository's Gateway mode routes at all is measured on freesailarr in production (#114) — that is evidence for the form, not for this chart and not for these routes.
  • That any policy is served. The chart registers a route; the domain lives in Stalwart's data store, which no chart value reaches. An entry whose domain is missing there gives a 404 at a correctly routed endpoint with every object green — and MTA-STS failures are silent on the sender's side, which delivers in the clear and logs nothing here. Documented in values.yaml, the schema, NOTES.txt (with a curl per host) and the wiki; documentation is not something a test can assert.
  • That cert-manager issues anything. Not installed in the k3d cluster, so the annotation is inert there and the named Secrets never exist. Harmless, because nothing mounts them.
  • NOTES.txt is read by no pipeline stage. helm template does not emit it and nothing under ci/ reads it, so its text carries review-only assurance. A rendering error in it does fail helm template, which is all CI can see. (Unchanged from group 3.)
  • The http-less node-group guard in both new blocks is unreachable, and I am reporting that rather than claiming it exercised: stalwart.sfs.yaml aborts first (nodes.full.exposedPorts does not include "http"). The identical guards in stalwart.ingress.yaml and stalwart.httproute.yaml are equally unreachable on main; the new ones are there for symmetry, not because they can fire.
  • Nothing was run in a cluster for this PR: no k3d, no Docker, no Trivy, no kubectl apply. The kubectl use was read-only (get ingress, get svc, get certificates) as system:serviceaccount:coder-workspaces:claude-readonly.

Not in this PR

  • The DNS records. _mta-sts.<domain> TXT; DNS, not Kubernetes.
  • mode: testing. Stalwart data-store configuration, per domain.
  • Removing the object from jk-cluster-config. A migration, not a chart change: remove there first, then fill the list — the other way round ingress-nginx's admission webhook rejects the second object on the same host and path. With the default [] this PR touches the running installation not at all. jk-cluster-config is untouched.
  • keycloak, rss-bridge, certificate.extraDnsNames (the rejected alternative — folding the names into the mail certificate would let a bad policy entry fail the certificate STARTTLS and IMAPS depend on).

README line 184 — the three-round collision, as it actually played out

I was the third of three parallel rounds, and all three change the HTTPRoute test's figure in that one sentence. It took two rebases, and the history is worth reading because the convention that resolved it was not mine:

  1. At PR open: neither other round had landed; line 184 ended at 56/21 and I appended 58/21.
  2. PR #124 (rss-bridge) merged, rewriting the paragraph and adding 8896fc8 "the 58/22 figure is a delta, not a running total". It had measured 58/22 from a different delta — both sentences said 58 and disagreed, the most confusing shape this could have taken. Rebase 1: kept its paragraph word for word, appended my delta, re-measured → 60/22.
  3. PR #127 and PR #125 (#122, realm hostnames) then merged, taking the line to the chain 56/21 → 58/22 → 62/22 and turning this PR mergeable: false. Rebase 2 onto 649417b: kept that whole chain verbatim and appended this round's delta.

The number is read, not computed, and that is this paragraph's own rule. 62 + 2 = 64 would be arithmetic over a branch whose substructure changed after its 2 were measured. The committed figure comes from ci/run-httproute-match-test.sh on the rebased branch and is confirmed by CI on the final head — runs/814/jobs/2740:

keycloak: ok (8 assertion(s) held, 2 not checked)
rss-bridge: ok (2 assertion(s) held, 0 not checked)
stalwart: ok (4 assertion(s) held, 0 not checked)
checked:       22
failed:        0
assertions held: 64

So this run is the one the convention was waiting for: being last of the three to be ready, its own run is "the first that holds all of them", and 64/22 is the first figure in that sentence that is a genuine running total rather than a delta. The arithmetic agreeing is a check, not the source — it only agrees because nothing else moved in between, and a round that changed an existing chart's routes instead of adding a chart would not add up that way at all.

Nothing of anyone else's text was reverted or reworded. Verified mechanically rather than asserted: README.md differs from origin/main in exactly one line, and that line starts with main's version character for character — the change is a pure append. The commit that set the obsolete 60/22 was dropped in the second rebase rather than carried, since its only content was a number that no longer holds.

Checked for substantive overlap, not just textual. The five commits main gained touch README.md, keycloak/**, rss-bridge/**, ci/keycloak/test-values.yaml, ci/httproute/keycloak.yaml and ci/httproute/rss-bridge.yaml — no stalwart/, no ci/stalwart/, no ci/httproute/stalwart.yaml, and ci/run-httproute-match-test.sh was not touched at all (it computes its figure, so it carries no number to go stale). The only overlap was README.md, exactly as expected. Re-verified after the rebase: both empty render diffs against 649417b still hold, helm lint --strict clean, stalwart still contributes 4.

The force-pushes were rebases of my own branch only — never main, never the wiki.

Wiki

4 pages, pushed after a fetch and rebase:

  • stalwart — a new ### mtaSts values section (entry form, the two object shapes and why they differ, the hardwired path, the rejected extraDnsNames alternative, the migration order), three new trap sections (route-not-domain, the subdomain wildcard, one Ingress per cluster issuer and why it cannot be one object), and the CI section updated to 4 assertions plus the install-test's coverage of the Ingress half.
  • CI-CD — the delta of +2, and after both other rounds merged the paragraph now cashes in the delta rule: 64 over 22, named as the first run holding all three. The intermediate 58/22, 60/21 and 60/22 are kept legible rather than tidied away, each correct for what it was read on and none of them a total, with 62 + 2 = 64 flagged as a check and not the source. Also why the chart count does not move — the first entry in that history where a chart already in the list gained an object — and that an overlay never covers the Ingress half of a both-modes feature. Corrected twice, since my first wiki commit said 58/21 and my second 60/22.
  • Chart-Style-Guide — the index rule's broken reason repaired (the three-step version above, replacing "acceptable only because a route holds no state and no certificate"), with stalwart and keycloak added as the two certificate-bearing cases and homeassistant named as the certificate-free original; plus §4's rule for a host list with per-entry secret and per-entry issuer — corrected from my own earlier "name after the issuer" to "name by index", with the NOTES.txt mapping obligation and the sharper reorder hazard that follow from it.
  • Lessons-Learned — "The two fields cert-manager reads at different scopes", the generalisation for the next list-of-hosts-with-its-own-TLS-secret; the second occurrence of #122's validation-precedence entry, appended to theirs rather than written as a competing one; and my own third point replaced, since it argued the position that was overruled. What replaces it is what the round actually taught: a rule can be right while its written reason is wrong; a justification is load-bearing because it is what someone reaches for when the rule meets a case its author did not foresee; and parallel rounds are a conformance test nobody wrote — run sequentially, the second would have followed the first and the broken reason would have survived unexamined.

No new page, so _Sidebar.md is unchanged.

The wiki push collided twice and was merged, never forced. Both other rounds had already pushed their wiki edits (their PRs are still open), so my first rebase hit a conflict in Lessons-Learned.md where their new section and mine were appended at the same place — resolved by keeping both, theirs first. A second commit landed while I was amending, so I fetched and rebased again. git rev-list --left-right --count origin/main...HEAD → 0 1 before each push; no force-push, nothing of theirs reverted. Wiki commit 28db5e2, signed.

Commits are signed (%G? → G). Opened under the bot account via tea.

Fixes #123

Gitea #123: `stalwart`'s MTA-STS policy route moves out of `jk-cluster-config` and into the chart as a **maintainable list**, rendering in **both** routing modes. `mtaSts.hosts: []` is the default, so this changes nothing about the running installation until it is filled. **Minor, additive, empty default** — `1.2.0+upv0.16.25` → `1.3.0+upv0.16.25`. `helm template` against `origin/main` is byte-identical for the defaults **and** for `ci/stalwart/test-values.yaml` as it stands on `origin/main`. --- ## Where I could not take the approved plan as written, and this is the part to review **Points 2, 4 and 9 of the issue are not jointly satisfiable, and the cause is cert-manager.** The plan asks for *one* Ingress with one rule per host (4), a *per-entry* optional `clusterIssuer` where empty means no annotation (2), and an overlay carrying one entry with an issuer and one without (9). Those three cannot all hold: - `cert-manager.io/cluster-issuer` is an **annotation** — object-scoped. One Ingress carries exactly one, or none, and with it **absent the shim creates nothing for that object** — for *every* `tls` entry on it, not for some. - `secretName` is **`tls`-entry-scoped**, and the shim creates one Certificate **per entry**. So one object cannot both request a certificate from issuer X and leave another host's secret alone, which is exactly what point 2's empty issuer has to mean. Requirement 9 therefore needs at least two objects. **And a second rule came out of measuring rather than reasoning.** `kubectl -n stalwart get certificates`, 2026-10-13: ``` Certificate tls-mta-sts-bekemeyer-klein-de-ingress secretName: tls-mta-sts-bekemeyer-klein-de-ingress ownerReferences: Ingress/mta-sts-ingress Certificate tls-stalwart-stalwart-ingress secretName: tls-stalwart-stalwart-ingress ownerReferences: Ingress/stalwart-stalwart-ingress ``` ingress-shim **names the Certificate after the secret** and owns it from the Ingress. Two objects whose `tls` entries name one secret therefore drive the shim at **one Certificate name from two owners**. **What I implemented instead:** one Ingress **per distinct `clusterIssuer`**, one rule per host inside it, `tls:` grouped by secret inside it, and a **render abort** when one secret appears under two issuers. Point 4's "one rule per host" and "tls grouped by secret" hold literally; only the object count splits, and only on the field that must. The grouping is `rss-bridge.tlsGroups` adopted rather than reinvented, as point 4 asks — what is new is the issuer dimension, which that chart does not have (it sets one chart-wide issuer on its single object). **Say so if you would rather have the restriction "all entries must agree on one issuer" and a single object**; that is the only other coherent shape and it would make requirement 9 impossible. **Object names: by index** — `<release>-stalwart-mta-sts-ingress`, `-1`, … in this repo's idiom (`{{ if $i }}-{{ $i }}{{ end }}`), as `homeassistant`'s routes and `keycloak`'s realm Ingresses do it. **This reverses an earlier draft of this PR, and the reversal is the interesting part.** I had named them by **issuer**, reading the style guide's index rule as resting on *"acceptable only because a route holds no state and no certificate"* and observing that these objects hold one. That reading was right — **and the rule's stated reason was wrong.** The parallel #122 round named by **index**, cited the same rule as binding, and entered `keycloak` into it, whose realm *Ingresses* own certificates too. So two charts did opposite things for one shape, and the rule had been extended onto exactly the cases its own precondition excluded. #125 is merged and published, so **the reason was repaired and the naming unified on index**; `keycloak` was not touched. What actually carries the index rule, in three steps with deliberately different standing: 1. **Measured** (by the maintainer, in `jk-cluster`): cert-manager runs **without `--enable-certificate-owner-ref`** — absent from the Deployment's args *and* from the `ControllerConfiguration` its `--config` names (which carries only `kind:` and `enableGatewayAPI: true`). The controller default applies. 2. **Documented behaviour** of that default, not measured here: the Certificate is owner-referenced from the Ingress and collected with it, but the **Secret is not** — cert-manager owner-references a Secret only with that flag on. A rename therefore recreates the *Certificate object* against a **surviving Secret** and **issues nothing**: no ACME request, no rate limit touched. 3. **Not applied:** no rename was performed against a cluster, on either round. Steps 1–2 are the mechanism, not a trial of it. **And the promise hangs on a cluster-level flag this repository does not control.** With it switched on, a reorder *would* cost a fresh issuance per renumbered object — worth checking before anyone enables it, and not something a chart can defend against. **Unchanged by the rename: the structure.** One Ingress per distinct `clusterIssuer`, one rule per host, `tls` grouped by secret, and the abort when a secret spans two issuers. Grouping is a cert-manager question, naming is a style question, and they are independent. **Two obligations the rename creates, both discharged:** - **`NOTES.txt` prints the mapping** — index, object name, issuer, secrets and hosts per object, the way `keycloak`'s does for its realms — because the name no longer carries the issuer and nothing else in the cluster says which object holds which. Visible in the install-test log on this head: `mta-sts-ingress` with `cluster issuer: "ci-selfsigned"`, `mta-sts-ingress-1` with `cluster issuer: NONE`. - **The reordering hazard is documented as *sharper* than `homeassistant`'s.** There an index names a host. Here it names an **issuer group derived from the list**, so moving one entry can change the grouping and renumber objects that were not themselves moved. **An asymmetry with `keycloak`, named in the template header:** there, both modes render one object per entry, so the index question arises twice. **Here it arises on the Ingress side only** — Gateway mode renders a single `HTTPRoute` carrying every hostname, TLS being none of a route's business, so there is no index and no name to go stale. **Version stays `1.3.0+upv0.16.25`:** nothing is published yet, so nothing in a release is being renamed. ## Where I reproduced the briefing exactly Every live measurement in the task held, with nothing to correct: | Claim | Reproduced | |---|---| | two Ingress objects in namespace `stalwart`, one from release `jk-cluster-config-fleet-cd-stalwart-mta-sts` | yes | | host `mta-sts.bekemeyer-klein.de`, **one** path `/.well-known/mta-sts.txt`, `pathType: Exact` | yes | | backend `stalwart-stalwart-full-http-service`, port **name** `http` | yes — and the Service's port number is **8080**, matching `ports.http.containerPort`, so the route's `backendRefs` number is read from the template and not guessed | | TLS secret `tls-mta-sts-bekemeyer-klein-de-ingress`, `cluster-issuer: letsencrypt-issuer`, **no** nginx annotations | yes (the only other annotations are Rancher/Fleet's `field.cattle.io` and `objectset.rio.cattle.io`, controller-added) | | the chart knows MTA-STS nowhere — `git grep -i 'mta-sts\|well-known'` over `stalwart/` on `origin/main` | yes, zero hits | | the live policy: `version: STSv1`, `mode: testing`, `max_age: 604800`, `mx: mail.bekemeyer-klein.de` | yes, `curl` against the live host | | `Chart.yaml` at `1.2.0+upv0.16.25` / `appVersion: "v0.16.25"` | yes | ## The two things the issue body did not say explicitly **1. The new objects hang on the mode switch correctly, and no required value does nothing.** Executed rather than assumed: | Configuration | Result | |---|---| | `ingress.enabled: true`, list set | 3 Ingress objects (main + 2 MTA-STS), 0 HTTPRoute | | `ingress.enabled: false`, `httpRoute.enabled: true`, list set | 2 HTTPRoute (WebUI + MTA-STS), **0 Ingress**, mail `Certificate` still rendered | | **both** `false`, list set | **0** of either, mail `Certificate` still rendered, and `NOTES.txt` says the list renders nothing and why | | Gateway mode, entry with **no** `tlsSecretName` and **no** `clusterIssuer` | **renders** — the required() is on the Ingress path only, so neither mode demands a value it ignores | | Ingress mode, entry with no `tlsSecretName` | aborts, naming the entry index, the host, and why there is no default | `ingress.domain` is untouched by all of this: it stays "REQUIRED either way" for the mail certificate's `dnsNames`, and the MTA-STS list is independent of it — the hosts are their own values, so nothing new hangs off it. **2. Nothing outside the routing templates reads `ingress.enabled` in this chart.** Screened over `stalwart/templates/` and `stalwart/values.schema.json`: the only readers are `stalwart.ingress.yaml`, `stalwart.httproute.yaml` (its mutual-exclusion guard) and `NOTES.txt`, which already goes through `$published = ingress.enabled OR httpRoute.enabled` from group 3. **No defect of the `freesailarr` / `ytdlp-web-player` class here** — measured, not assumed. `stalwart.certificate.yaml` reads `ingress.domain` and `ingress.clusterIssuer`, both with their own `required()`, which is correct and unchanged. ## A defect found *after* opening this PR, and found because the parallel round wrote its lesson down Worth reading as a result about the process as much as about the chart. While pushing the wiki I had to rebase onto the **keycloak round's** (#122) `Lessons-Learned` entry, read it, and recognised my own code in it: *a specific validation must not be reachable in a state that a structural validation already rejects.* Rendered the combination it names, against my own branch — **and it reproduced:** ``` # both modes on AND an entry missing its tlsSecretName Error: ... mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName, and in Ingress mode every entry needs one: ... ``` True, and the **wrong diagnosis**: the values file's defect is that `ingress.enabled` and `httpRoute.enabled` are both true, and "in Ingress mode" is a claim about a configuration that is not coherent at all. Whoever met it would add a `tlsSecretName`, re-run, and reach the real message one round later — or conclude this feature was the problem. **Fixed in `9f96204` by scope, not by ordering** (Helm renders templates in an order a chart cannot pin, so this template may not assume the httproute template's abort runs first): the Ingress-mode MTA-STS block is gated on `ingress.enabled` **and not** `httpRoute.enabled`, so in the incoherent combination it validates nothing and the only `fail` left is the structural one. Re-measured after the fix: | Configuration | Message | |---|---| | both modes on, `tlsSecretName` missing | the **mode-conflict** abort | | both modes on, entry complete | the **mode-conflict** abort | | Ingress mode alone, `tlsSecretName` missing | the **per-entry** abort — so the guard is **not** dead | All three empty render diffs and the assertion figure were re-measured after the fix and were unchanged by it (it was `58`/`21` at that point; see the rebase history below for why that number moved twice afterwards for reasons that have nothing to do with this fix). **Two independent charts met this in the same round, from two agents, neither having seen the other's code** — so I added that second occurrence to their `Lessons-Learned` entry rather than writing a competing one, since one chart's story and two charts' is the difference between an anecdote and a rule. **And the `#`-comment trap was avoided.** Every explanatory comment in the new template blocks is inside `{{/* */}}` or `{{- /* */}}`. Confirmed twice: by the empty render diffs, and by grepping the *rendered* MTA-STS objects in both modes for stray `#` lines — 0 in each. ## Verification - `ci/helm-dependencies.sh stalwart` first (valkey), or every helm command exits 1. - `helm lint --strict` three ways — defaults, CI values, CI values + Gateway overlay: `1 chart(s) linted, 0 chart(s) failed` each. `helm package` → `stalwart-1.3.0+upv0.16.25.tgz`. - **Empty `helm template` diff against `origin/main`, twice**: the defaults (14 objects, identical) and `ci/stalwart/test-values.yaml` **as it stands on `origin/main`**, plus a third for the Gateway overlay as it stands there. With the new fixture the diff is non-empty by construction — that is the feature, and it adds 2 Ingress objects. - **Non-empty render inspected in both modes** with a four-entry list (with issuer / subdomain / **sharing** entry 1's secret / no issuer): Ingress mode gives 2 MTA-STS objects — the `letsencrypt-issuer` one carrying 3 rules and **2** `tls` entries, with entries 1 and 3 collapsed under one secret as 2 SANs, and the issuer-less one carrying 1 rule and **no annotation**. Gateway mode gives 1 route, 4 hostnames, `type: Exact`, `port: 8080`. - `ci/run-httproute-match-test.sh`: **64 assertions held over 22 charts, 0 failed**, read from CI on the final head (`runs/814/jobs/2740`) — **unchanged by the rename, which was expected but measured rather than assumed**: object names are not assertions. `stalwart` 2 → **4**, both routes' `parentRefs` and both `backendRefs` resolving to `httproute-stalwart-full-http-service:8080` in the same render. `ci/run-quantity-render-test.sh` (28 exercised, 0 failed) and `ci/run-selector-match-test.sh` (5 selectors, 0 failed) both clean. - **Schema in both directions:** every overlay and fixture accepted; `mtaSts.hosts[0].bogus` rejected (`additional properties 'bogus' not allowed`); an entry without `host` rejected (`missing property 'host'`) — caught by the schema *before* the template; and **`mtaSts.enabled` rejected**, so "the list is the switch" is enforced rather than merely documented. - **Fail paths executed, not assumed:** incomplete entry (no `host`), missing `tlsSecretName` in Ingress mode, both modes at once **with the list set** (the existing abort fires, naming both value paths), a **duplicate host** — which aborts in **both** modes, from each template, since the guard sits in the shared helper — and one **secret under two issuers**. Each printed its full message and exited non-zero. - **`NOTES.txt` reviewed by reading, in all three branches** (Ingress / Gateway / neither), rendered through a scratch chart copy because `helm template` does not emit notes — and `helm install --dry-run` cannot be used for the Gateway branch here, since this workspace's identity is forbidden `httproutes` and helm looks the object up. **One layout defect found that way and fixed**: the `→ node group` line sat after a two-lines-per-entry host list and read as part of the last entry; it now precedes the list. ## What stays unproven, by construction - **That traffic flows.** No Gateway controller runs in CI and none will, so **no `HTTPRoute` ever reaches `Accepted: True`**. That this repository's Gateway mode routes at all is measured on `freesailarr` **in production** (#114) — that is evidence for the *form*, not for this chart and not for these routes. - **That any policy is served.** The chart registers a route; the **domain** lives in Stalwart's data store, which no chart value reaches. An entry whose domain is missing there gives a **404 at a correctly routed endpoint** with every object green — and MTA-STS failures are silent **on the sender's side**, which delivers in the clear and logs nothing here. Documented in `values.yaml`, the schema, `NOTES.txt` (with a `curl` per host) and the wiki; documentation is not something a test can assert. - **That cert-manager issues anything.** Not installed in the k3d cluster, so the annotation is inert there and the named Secrets never exist. Harmless, because nothing mounts them. - **`NOTES.txt` is read by no pipeline stage.** `helm template` does not emit it and nothing under `ci/` reads it, so its text carries review-only assurance. A rendering *error* in it does fail `helm template`, which is all CI can see. (Unchanged from group 3.) - **The `http`-less node-group guard in both new blocks is unreachable**, and I am reporting that rather than claiming it exercised: `stalwart.sfs.yaml` aborts first (`nodes.full.exposedPorts does not include "http"`). The identical guards in `stalwart.ingress.yaml` and `stalwart.httproute.yaml` are equally unreachable on `main`; the new ones are there for symmetry, not because they can fire. - **Nothing was run in a cluster for this PR**: no k3d, no Docker, no Trivy, no `kubectl apply`. The `kubectl` use was read-only (`get ingress`, `get svc`, `get certificates`) as `system:serviceaccount:coder-workspaces:claude-readonly`. ## Not in this PR - **The DNS records.** `_mta-sts.<domain>` TXT; DNS, not Kubernetes. - **`mode: testing`.** Stalwart data-store configuration, per domain. - **Removing the object from `jk-cluster-config`.** A migration, not a chart change: **remove there first, then** fill the list — the other way round ingress-nginx's admission webhook rejects the second object on the same host and path. With the default `[]` this PR touches the running installation not at all. `jk-cluster-config` is untouched. - `keycloak`, `rss-bridge`, `certificate.extraDnsNames` (the rejected alternative — folding the names into the mail certificate would let a bad policy entry fail the certificate STARTTLS and IMAPS depend on). ## README line 184 — the three-round collision, as it actually played out I was the **third of three parallel rounds**, and all three change the HTTPRoute test's figure in that one sentence. It took **two rebases**, and the history is worth reading because the convention that resolved it was not mine: 1. **At PR open:** neither other round had landed; line 184 ended at `56`/`21` and I appended **`58`/`21`**. 2. **PR #124 (`rss-bridge`) merged**, rewriting the paragraph and adding `8896fc8` *"the 58/22 figure is a delta, not a running total"*. It had measured **`58`/`22`** from a different delta — both sentences said `58` and disagreed, the most confusing shape this could have taken. **Rebase 1:** kept its paragraph word for word, appended my delta, re-measured → **`60`/`22`**. 3. **PR #127 and PR #125 (#122, realm hostnames) then merged**, taking the line to the chain `56/21 → 58/22 → 62/22` and turning this PR `mergeable: false`. **Rebase 2 onto `649417b`:** kept that whole chain verbatim and appended this round's delta. **The number is read, not computed, and that is this paragraph's own rule.** `62 + 2 = 64` would be arithmetic over a branch whose substructure changed after its `2` were measured. The committed figure comes from `ci/run-httproute-match-test.sh` on the rebased branch and is confirmed by CI on the final head — `runs/814/jobs/2740`: ``` keycloak: ok (8 assertion(s) held, 2 not checked) rss-bridge: ok (2 assertion(s) held, 0 not checked) stalwart: ok (4 assertion(s) held, 0 not checked) checked: 22 failed: 0 assertions held: 64 ``` **So this run is the one the convention was waiting for:** being last of the three to be ready, its own run is "the first that holds all of them", and `64`/`22` is the first figure in that sentence that is a genuine **running total** rather than a delta. The arithmetic agreeing is a **check, not the source** — it only agrees because nothing else moved in between, and a round that changed an existing chart's routes instead of adding a chart would not add up that way at all. **Nothing of anyone else's text was reverted or reworded.** Verified mechanically rather than asserted: `README.md` differs from `origin/main` in **exactly one line**, and that line **starts with `main`'s version character for character** — the change is a pure append. The commit that set the obsolete `60/22` was **dropped** in the second rebase rather than carried, since its only content was a number that no longer holds. **Checked for substantive overlap, not just textual.** The five commits `main` gained touch `README.md`, `keycloak/**`, `rss-bridge/**`, `ci/keycloak/test-values.yaml`, `ci/httproute/keycloak.yaml` and `ci/httproute/rss-bridge.yaml` — **no `stalwart/`, no `ci/stalwart/`, no `ci/httproute/stalwart.yaml`**, and `ci/run-httproute-match-test.sh` was not touched at all (it computes its figure, so it carries no number to go stale). The only overlap was `README.md`, exactly as expected. Re-verified after the rebase: both **empty render diffs** against `649417b` still hold, `helm lint --strict` clean, `stalwart` still contributes `4`. The force-pushes were rebases of **my own branch** only — never `main`, never the wiki. ## Wiki 4 pages, pushed after a fetch and rebase: - **`stalwart`** — a new `### mtaSts` values section (entry form, the two object shapes and why they differ, the hardwired path, the rejected `extraDnsNames` alternative, the migration order), three new trap sections (*route-not-domain*, *the subdomain wildcard*, *one Ingress per cluster issuer and why it cannot be one object*), and the CI section updated to 4 assertions plus the install-test's coverage of the Ingress half. - **`CI-CD`** — the delta of `+2`, and after both other rounds merged the paragraph now **cashes in the delta rule**: `64` over `22`, named as the first run holding all three. The intermediate `58/22`, `60/21` and `60/22` are kept legible rather than tidied away, each correct for what it was read on and none of them a total, with `62 + 2 = 64` flagged as a check and not the source. Also why the chart count does not move — the first entry in that history where a chart *already* in the list gained an object — and that an overlay never covers the Ingress half of a both-modes feature. **Corrected twice**, since my first wiki commit said `58/21` and my second `60/22`. - **`Chart-Style-Guide`** — **the index rule's broken reason repaired** (the three-step version above, replacing *"acceptable only because a route holds no state and no certificate"*), with `stalwart` and `keycloak` added as the two certificate-bearing cases and `homeassistant` named as the certificate-free original; plus §4's rule for a host list with per-entry secret *and* per-entry issuer — corrected from my own earlier "name after the issuer" to "name by index", with the `NOTES.txt` mapping obligation and the sharper reorder hazard that follow from it. - **`Lessons-Learned`** — *"The two fields cert-manager reads at different scopes"*, the generalisation for the next list-of-hosts-with-its-own-TLS-secret; the second occurrence of #122's validation-precedence entry, appended to theirs rather than written as a competing one; and **my own third point replaced**, since it argued the position that was overruled. What replaces it is what the round actually taught: a rule can be right while its written reason is wrong; a justification is load-bearing because it is what someone reaches for when the rule meets a case its author did not foresee; and **parallel rounds are a conformance test nobody wrote** — run sequentially, the second would have followed the first and the broken reason would have survived unexamined. No new page, so `_Sidebar.md` is unchanged. **The wiki push collided twice and was merged, never forced.** Both other rounds had already pushed their *wiki* edits (their PRs are still open), so my first rebase hit a conflict in `Lessons-Learned.md` where their new section and mine were appended at the same place — resolved by keeping **both**, theirs first. A second commit landed while I was amending, so I fetched and rebased again. `git rev-list --left-right --count origin/main...HEAD` → `0 1` before each push; no force-push, nothing of theirs reverted. Wiki commit `28db5e2`, signed. Commits are signed (`%G?` → `G`). Opened under the bot account via `tea`. Fixes #123
jklein force-pushed feat/stalwart-mta-sts-routes from 9f9620431f to bf7640104f 2026-10-13 12:32:29 +02:00 Compare
jklein added 3 commits 2026-10-13 12:42:23 +02:00
The policy route for mta-sts.<domain> belonged to another repository
(jk-cluster-config) while pointing at this chart's own Service. It moves
here as mtaSts.hosts, a list, so several domains - subdomains included -
are maintainable in one place and in whichever routing mode is active.

THE LIST IS THE SWITCH. No mtaSts.enabled: "enabled with an empty list"
would be a state with no meaning that the chart would then have to catch.
The schema rejects the key outright, so the rule is enforced rather than
documented. Default [] renders nothing in either mode - the defaults and
the unchanged CI fixture both produce a byte-identical helm template
against origin/main.

Entry form {host, tlsSecretName, clusterIssuer?}. The two TLS fields are
read ONLY in Ingress mode; in Gateway mode TLS sits on the listener, so
requiring them there would demand a value that does nothing.

THE PATH IS HARDWIRED. RFC 8461 fixes /.well-known/mta-sts.txt, rendered
with pathType Exact (type: Exact in the route). A values key for a
protocol-mandated path can only ever be set wrong, and wrong is invisible
from this side. Exact also matters in itself: a prefix on these hostnames
would publish the mail server's whole management surface under them.

ONE INGRESS PER CLUSTER ISSUER, NOT ONE OBJECT - and the plan's own three
requirements forced that. cert-manager reads the two fields at different
scopes: cluster-issuer is an ANNOTATION (object-scoped, and absent means
nothing is issued for that object at all), while secretName is
tls-entry-scoped and yields one Certificate per entry. So an object cannot
both request a certificate and leave another host's secret alone, which is
what an empty per-entry issuer has to mean. Measured in jk-cluster:
ingress-shim names the Certificate AFTER THE SECRET and owns it from the
Ingress, so a shared secret must sit in exactly one object (hence grouping
the tls list by secret, reusing rss-bridge.tlsGroups' shape) and a secret
may not span two issuer groups (hence the abort). Objects are named by
issuer, not by index: an index is only free where the object holds no
certificate, which is the precondition homeassistant's route naming states
for itself.

Gateway mode renders ONE HTTPRoute with every hostname - nothing differs
per host there - attached to the same Gateway(s) as the WebUI route.

WHAT THE CHART CANNOT PROMISE, AND THEREFORE NAMES: it registers a route,
it does not create a domain. The domains live in Stalwart's data store, so
an entry whose domain is missing there answers 404 at a correctly routed
endpoint with every object green - and MTA-STS failures are silent on the
SENDER's side, which delivers in the clear and logs nothing here. Same for
the _mta-sts.<domain> TXT record and the policy's content. The subdomain
trap is documented too: mta-sts.sub.<domain> is one label below
*.<domain>, so a parent wildcard certificate does not cover it.

Not folded into certificate.extraDnsNames deliberately - that would hang
the MAIL certificate on this list, letting a bad policy entry take STARTTLS
and IMAPS with it.

Minor, additive, empty default: 1.2.0+upv0.16.25 -> 1.3.0+upv0.16.25.

CI: ci/httproute/stalwart.yaml enters all three shapes of the entry form
(with issuer, without, and a subdomain), taking the HTTPRoute render test
from 56 assertions over 21 charts to 58 over 21 - stalwart from 2 to 4.
The Ingress half cannot be covered there, since an overlay's whole job is
to switch the mode off, so ci/stalwart/test-values.yaml carries two
entries and the install-test has a real API server accept them.

Fixes #123
With BOTH routing modes on and an mtaSts entry missing its tlsSecretName,
the render reported

  mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName, and in
  Ingress mode every entry needs one

which is true and is the wrong diagnosis: the values file's defect is that
ingress.enabled and httpRoute.enabled are both true, and "in Ingress mode"
is a claim about a configuration that is not coherent at all. Whoever met
it would add a tlsSecretName, re-run, and reach the real message one round
later - or conclude the MTA-STS feature was the problem.

Measured on this chart, not reasoned about. And it is the second occurrence
of a defect the parallel keycloak round of Gitea #122 found on its own
realm list, independently and in the same round, which is what makes it a
rule rather than one chart's story: a specific validation must not be
reachable in a state that a structural validation already rejects.

Ordering is not the fix and could not be - Helm renders templates in an
order a chart cannot pin, so this template may not assume the httproute
template's abort runs first. Scope is: the Ingress-mode MTA-STS block is
now gated on ingress.enabled AND NOT httpRoute.enabled, so in the
incoherent combination it validates nothing and the only fail left is the
one that names the real problem.

The per-entry guard is NOT dead by this - verified reachable with
ingress.enabled alone. Defaults, the unchanged CI fixture and the overlay
all still render byte-identically against origin/main, and the HTTPRoute
render test still holds 58 assertions over 21 charts.
docs(README): the MTA-STS delta on top of both landed rounds, measured at 64/22
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 24s
Install-test / Install-test stalwart (pull_request) Successful in 1m36s
Validate Helm Charts / Install-test (pull_request) Successful in 1m36s
71b757bf9f
Second rebase of this branch, and the last one the parallel rounds force.
Both have now merged - rss-bridge plus its documentation corrections, and
the realm hostnames of #122 - so line 184 carries the chain
56/21 -> 58/22 -> 62/22 with the delta convention #124 introduced.

That chain is kept WORD FOR WORD and this round's delta is appended to it;
the only change to the line is an append, verified line by line against
origin/main rather than asserted.

THE NUMBER IS READ, NOT COMPUTED, and that distinction is this paragraph's
own rule. 62 + 2 = 64 is arithmetic over a branch that had a different
substructure when its 2 were measured; the figure committed here comes from
ci/run-httproute-match-test.sh on THIS branch, rebased onto the main that
already holds both other rounds: 64 assertions over 22 charts, 0 failed,
with stalwart contributing 4. It is therefore the first figure in that
sentence that is a genuine running total rather than a delta - the first run
that contains all three rounds, which is exactly where the convention says
to read it.

The earlier 58/21 and 60/22 in this branch's history were each correct for
the substructure they were measured on and are both superseded. The commit
that set 60/22 was dropped in this rebase rather than carried, since its
only content was a number that no longer holds.
jklein force-pushed feat/stalwart-mta-sts-routes from bf7640104f to 71b757bf9f 2026-10-13 12:42:23 +02:00 Compare
jklein added 1 commit 2026-10-13 13:03:42 +02:00
refactor(stalwart): name the MTA-STS Ingress objects by index, per the corrected rule
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 29s
Install-test / Install-test stalwart (pull_request) Successful in 1m42s
Validate Helm Charts / Install-test (pull_request) Successful in 1m42s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 13s
SonarQube Analysis / SonarQube Analysis (push) Successful in 50s
978e6fbaa8
Maintainer's decision, and it reverses this chart's naming rather than the
style guide's rule. The objects become

  <release>-stalwart-mta-sts-ingress, -1, -2, ...

in this repo's idiom ({{ if $i }}-{{ $i }}{{ end }}), the same as
homeassistant's routes and keycloak's realm Ingresses, inlined in the
template instead of behind a helper of its own.

WHY IT GOES THIS WAY. This chart named by issuer, reading the style guide's
index rule as resting on "a route holds no state and no certificate" and
observing that these objects hold one. That reading was right and the
rule's STATED REASON was wrong: the parallel #122 round named by index,
cited the same rule as binding, and entered keycloak into it - whose realm
Ingresses hold certificates too. Two charts did opposite things for one
shape, and the rule had been extended onto cases its own precondition
excluded. #125 is merged and published, so the reason is repaired and the
naming unified on index; keycloak is not touched.

What actually carries the index rule, in three steps that have different
standing and are written as such in the template header and the style guide:
cert-manager here runs WITHOUT --enable-certificate-owner-ref (MEASURED by
the maintainer - absent from the Deployment's args and from the
ControllerConfiguration its --config names, which carries only
kind: ControllerConfiguration and enableGatewayAPI: true); by that default
the Certificate is collected with its Ingress but the SECRET IS NOT
(DOCUMENTED behaviour, not measured here), so a rename recreates the
Certificate object against a surviving secret and issues nothing; and NO
RENAME WAS APPLIED against a cluster. The promise therefore hangs on a
cluster-level flag this repository does not control - with it on, a reorder
would cost a fresh ACME request per renumbered object.

UNCHANGED: the structure. One Ingress per distinct clusterIssuer, one rule
per host, tls grouped by secret, and the abort when one secret spans two
issuers - grouping is a cert-manager question and naming is a style
question, and they are independent.

TWO THINGS THE RENAME OBLIGES.

1. NOTES.txt now prints the mapping - index, object name, issuer, secrets
   and hosts per object, the way keycloak's does for its realms - because
   the name no longer carries the issuer and nothing else in the cluster
   says which object holds which.
2. The reordering hazard is documented as SHARPER than homeassistant's:
   there an index names a host, here it names an issuer GROUP derived from
   the list, so moving one entry can change the grouping and renumber
   objects that were not themselves moved.

AN ASYMMETRY WITH keycloak, named in the template header: there both modes
render one object per entry, so the index question arises twice. Here it
arises on the Ingress side ONLY - Gateway mode renders one HTTPRoute
carrying every hostname, because TLS is none of the route's business, so
there is no index and no name to go stale.

Version stays 1.3.0+upv0.16.25: nothing is published yet, so nothing is
being renamed in a release.

Re-measured after the rename, not assumed: three empty helm template diffs
against origin/main (defaults, CI fixture, overlay), helm lint --strict
clean three ways, every abort path still firing with the structural one
still winning over the per-entry one, the schema still rejecting
mtaSts.enabled, and the HTTPRoute render test still at 64 assertions over
22 charts - object names are not assertions.
jklein approved these changes 2026-10-13 13:19:13 +02:00
jklein merged commit 978e6fbaa8 into main 2026-10-13 13:19:39 +02:00
jklein deleted branch feat/stalwart-mta-sts-routes 2026-10-13 13:19:39 +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#126