Files
helm-charts/stalwart/Chart.yaml
T
jklein 3e969c5659 feat(stalwart): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. ONE route - not one per node group. #114 describes
this chart as rendering "per node group"; its SERVICES and StatefulSets are,
the routing object is not and must not be. The route targets exactly
ingress.nodeGroup's own http Service and keeps both of the Ingress's guards,
because the WebUI's setup and management screens are not safe to spread over a
group that may not run the management tasks. ingress.nodeGroup is therefore
shared by both modes: it names where the management surface is published from,
which is not a property of the Ingress.

THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
anything of the TLS path does. stalwart.certificate.yaml covers STARTTLS on 25
and implicit TLS on 465/993; those connections pass through neither an ingress
controller nor a Gateway. So in Gateway mode ingress.domain is still required
(the certificate's dnsNames read it), ingress.clusterIssuer is still required
unless certificate.clusterIssuer is set, and the Certificate must NOT be
removed as part of a migration - deleting it drops SMTP and IMAP back to
Stalwart's self-signed certificate, with clients seeing a name mismatch while
every Kubernetes object stays green. Verified in the render: Gateway mode emits
Certificate + HTTPRoute and no Ingress.

WHAT THIS MODE DROPS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
all of it operator-supplied through ingress.annotations (this chart sets none
of it):

    enable-cors: true
    cors-allow-origin:  https://webmail.bekemeyer-klein.de
    cors-allow-methods: GET, POST, OPTIONS
    cors-allow-headers: Authorization, Content-Type

That origin is BULWARK, the webmail chart in this same group. Without the CORS
configuration the browser-side webmail cannot call this server's JMAP endpoint
at all, and the failure is entirely client-side: a console error, nothing in
the server's logs.

AND "no counterpart" WOULD HAVE BEEN WRONG. Measured by grepping the CRDs this
repo pins (gateway-api v1.6.3 standard channel, ci/gateway-api-crds.sh) rather
than inferred: HTTPRoute's filters carry a full 'cors' field (GEP-1767) with
allowOrigins/allowMethods/allowHeaders/allowCredentials/maxAge, and its
documented level - verbatim from that CRD - is "Support: Extended".
responseHeaderModifier is in the same CRD and is also Extended, not Core.

The chart still does not render it, for three reasons in order:

  1. The chart has NO CORS values. The live configuration is the operator's,
     and the chart sets no CORS annotations in Ingress mode either - so
     rendering a filter means inventing a values surface that would exist in
     Gateway mode only. That asymmetry is its own defect and its own work.
  2. Extended support is not support. A rejected route is rejected WHOLE,
     which would take the WebUI and JMAP down together (the reasoning #119
     reached for statusCode); and the observed failure is quieter - Cilium
     accepts filters.type: CORS and forwards the preflight upstream anyway
     (cilium/cilium#45272), i.e. Accepted: True with the behaviour absent.
  3. Half a CORS is worse than none, and the translatable half is the useless
     one HERE. A responseHeaderModifier can add the Access-Control-Allow-*
     headers; the PREFLIGHT has to be answered BY THE GATEWAY, which the CORS
     filter's own contract states and a bare header filter cannot do. Because
     the allowed headers include Authorization, EVERY webmail request is
     preflighted - so the expressible half is exactly the half that does not
     help.

Named, not rebuilt (decision 2 of #114), in values.yaml, in the schema and in
the Gateway branch of NOTES.txt, with the operational instruction: configure
CORS at the Gateway with that implementation's own support BEFORE moving this
host, and test it from the webmail in a browser - curl sends no preflight, so
it passes while the webmail fails.

NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) where it
asked ingress.enabled, and two paragraphs that named the ingress controller
specifically now name the Gateway in that mode: the auto-ban step (switching
API does not avoid or reset it - one proxy address instead of another) and the
NetworkPolicy paragraph, where the namespace to allow is the Gateway's data
plane rather than an ingress controller's.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/stalwart/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00

137 lines
7.8 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: the
# per-group spread constraint and the per-group PodDisruptionBudget of Gitea #96
# are both gated on the COMPUTED replica count being >= 2, and the chart ships
# full: 1 / mx: 0. `helm template` against the previous version therefore renders
# byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml
# - measured, both empty diffs. New optional values with unchanged defaults is
# exactly the minor case (README, "Versioning").
version: 1.2.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.