Files
helm-charts/stalwart/Chart.yaml
T
jklein b4c9823e01 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
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
2026-10-13 10:41:10 +00:00

140 lines
8.0 KiB
YAML

apiVersion: v2
name: stalwart
description: A Helm chart to deploy a cluster-capable Stalwart mail server (SMTP, IMAP, JMAP, ManageSieve) on Kubernetes
icon: https://avatars.githubusercontent.com/u/76623657
type: application
# appVersion is the exact image tag, and the "v" is part of it - so it is part
# of appVersion and of the "+up" suffix. Precedent: calibre-web-automated
# (+upv4.0.6), matrix-synapse (+upv1.159.0), ytdlp-web-player (+upv1.2.0).
#
# The glibc image, not "-alpine". Both flavours are published for every release
# from the same source and carry the same uid/gid 2000 and the same two volumes;
# the choice is a deployment decision, and keeping it out of the tag is what
# lets renovate.json5 anchor the version with a plain three-part regex instead
# of maintaining a compatibility suffix. The moving tags "latest", "v0.16" and
# their -alpine twins are excluded by that same anchor: measured on 2026-10-02
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
# apart, so either floating name would change the running image with no commit
# in this repository and nothing to report it.
# MINOR, not major, and the evidence is a diff rather than a judgement. That
# held for the per-group spread constraint and PodDisruptionBudget of Gitea #96
# (both gated on the COMPUTED replica count being >= 2, and the chart ships
# full: 1 / mx: 0) and it holds again for the MTA-STS routes of Gitea #123: the
# list is the switch and it ships EMPTY, so no object is added and no value
# becomes required in either routing mode. `helm template` against the previous
# version renders byte-for-byte identically for the defaults AND for
# ci/stalwart/test-values.yaml as it stands on origin/main - measured, both
# empty diffs. New optional values with unchanged defaults is exactly the minor
# case (README, "Versioning").
version: 1.3.0+upv0.16.25
appVersion: "v0.16.25"
maintainers:
- name: jklein
# The second chart in this repo with a dependency (after ytdlp-web-player).
# README, "Chart dependencies", holds the bar it has to clear and the price;
# both are restated here because the price is paid by whoever reads this file
# next, not by whoever added the line.
#
# WHY ONE HERE. Stalwart's in-memory store is not a cache that can be dropped:
# from v0.16 it also carries the cluster coordinator ("the Default variant
# reuses the Redis connection already configured on the in-memory store"), so a
# multi-node install does not work without it. Redis/Valkey is admissible as
# the in-memory store ONLY - never as the data or blob store. Offering it inside
# the chart means the wiring (host, port, egress rule) is in one place instead
# of split across two releases that have to agree.
#
# WHY IT IS OFF BY DEFAULT: an installation that already runs a Valkey points
# externalServices.valkey.host at it and this dependency renders nothing. The
# subchart is the path for an installation that has none.
#
# THE SUBCHART IS OUR OWN valkey CHART, resolved from the published index -
# NOT upstream's. So guideline 14.2 ("we consume their chart as it comes") does
# not apply here and there is nothing of someone else's to leave unpatched: the
# subchart follows this repo's conventions because it IS this repo's chart. What
# does apply is everything about the subchart being invisible downstream, plus
# one hazard that a self-referencing dependency adds and an external one does
# not - see the PR-ordering warning below.
dependencies:
- name: valkey
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has.
#
# A subchart is invisible to everything downstream that watches Helm release
# versions: what this file declares is baked into our packaged .tgz, and
# Fleet only ever sees the version of "stalwart". So a new valkey chart - a
# security fix included - reaches a cluster ONLY when this line is raised
# and a new stalwart version is published. Nothing reports the gap.
#
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR (no
# customManagers entry exists or would help - repository and version sit
# together in one file, which is the case that manager is for), and
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
# actually ships it. That chain is the whole safety net; switched off, this
# line needs a human on a calendar instead.
#
# MEASURED, not assumed, because the registry is self-hosted and the version
# carries build metadata - two things that could each have broken the chain
# silently. Renovate 44.132.2, dry run on 2026-10-02 with this dependency
# pinned at a lower version: manager helmv3 matched the file, took
# registryUrls from the line below, and offered
# newVersion: "1.0.0+up9.1.2.alpine", versioning: "helm"
# with the release timestamp read from our index. So both the self-hosted
# index and the "+up..." suffix are handled.
#
# Independent of appVersion: raising it is a patch or minor bump of this
# chart and is never tied to the Stalwart image.
version: "1.0.0+up9.1.2.alpine"
# Our own published index, and the READ path only. It is anonymously
# readable - measured 2026-10-02: GET .../index.yaml returns 200 without
# credentials - so ci/helm-dependencies.sh needs no secret and was not
# touched. Verified with an empty HELM_REPOSITORY_CONFIG, i.e. the state a
# fresh runner is in: helm reports "Getting updates for unmanaged Helm
# repositories" and resolves the exact build-metadata version from the URL
# alone, with no "helm repo add" anywhere. Both "helm dependency update" and
# "helm dependency build" were tried.
#
# The PUBLISH target and its credentials still live in 1Password and appear
# nowhere in this repository - see README, "CI/CD".
repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"
# Off by default, so a plain install renders none of its objects.
#
# NOT "valkey.enabled", AND THAT IS NOT A STYLE CHOICE. Everything under the
# "valkey:" key is passed to the subchart as its values, and our valkey
# chart ships a values.schema.json with additionalProperties: false and no
# "enabled" property - so "valkey.enabled" is rejected by the SUBCHART:
# Error: values don't meet the specifications of the schema(s) in the
# following chart(s): valkey: - at '': additional properties 'enabled'
# not allowed
# Measured. Worse, Helm skips a disabled subchart's schema validation, so
# the error appears only for whoever first sets it to true - the default
# path renders clean.
#
# The condition is therefore a key of OUR OWN, outside the subchart's value
# subtree, which is also the honest shape: "enabled" is not a valkey value.
# It only ever looked like one because upstream's schema was permissive.
# The alternative - adding "enabled" to valkey/values.schema.json - would
# have to be its own PR and be published first (see the warning below), for
# a key that chart does not read.
condition: valkeySubchart.enabled
#
# !!! NEVER CHANGE valkey/ AND THIS CHART IN THE SAME PULL REQUEST !!!
#
# The hazard a self-referencing dependency adds. This dependency resolves out of
# the PUBLISHED index, not out of the working tree, and valkey/ is only
# published by a push to main that touches it. So in one PR carrying both:
#
# - every helm command here - lint, package, render, AND the install-test -
# pulls the OLD valkey .tgz, because the new one does not exist yet;
# - the PR therefore goes green against a version of the subchart that is not
# the one in the diff, and the diff to valkey/ is reviewed while nothing
# actually exercised it;
# - after the merge both charts publish, and the first install of the new
# stalwart is the first time the new valkey is used anywhere.
#
# Two consecutive pull requests instead: change valkey/, merge, let it publish,
# then raise the version here. The ordering is not a nicety - the green check on
# the combined PR is a statement about the wrong artifact.