Gitea #143, maintainer's instruction. The HBONE port and the redirected probe source are ISTIO CONSTANTS, not standards, so they move from template literals into values with documented defaults - an operator can then correct them when Istio changes a number, without waiting for a chart release. One object under one "enabled", so the two halves still cannot be set separately; two values could drift apart, which is what this design exists to prevent. Every read is null-safe (#99), so a deleted object or one carrying only "enabled: true" falls back to the defaults declared here rather than dereferencing nil - the defect #136 shipped one value away. Provenance now written on each constant: 15008 is Istio's HBONE port, not IANA-registered for this and unknown to Kubernetes; 169.254.7.127 is link-local (RFC 3927, never routed), its IPv6 counterpart carries the same digits plus 1337, and neither the NetworkPolicy API nor the kubelet knows the address - only Istio's data plane rewrites the probe source onto it. Both read for ztunnel 1.30.6, and a change in either fails silently. The exception in ci/run-networkpolicy-test.sh stays pinned to the numbers that test names, and additionally asserts this chart declares the same defaults - never "whatever the value says", which would let a mistyped port in through a configurable field. No version change: nothing is published yet, the default is still off, and the render at defaults is byte-identical - re-diffed against origin/main.
1342 lines
73 KiB
YAML
1342 lines
73 KiB
YAML
# Stalwart — a cluster-capable mail server (SMTP, IMAP, POP3, JMAP, ManageSieve).
|
|
#
|
|
# READ THIS FIRST: THIS CHART CONFIGURES ALMOST NOTHING.
|
|
#
|
|
# Since v0.16 Stalwart keeps its entire configuration — over 2400 settings in
|
|
# 117 object types, including every listener, the blob store, the in-memory
|
|
# store, TLS objects, domains and the cluster roles — INSIDE the data store,
|
|
# managed over the JMAP API (WebUI or stalwart-cli). The only thing left on
|
|
# disk is a small config.json that names the data store and nothing else:
|
|
# "It only needs to contain the DataStore object"
|
|
# (https://stalw.art/docs/configuration/declarative-deployments).
|
|
#
|
|
# So this chart renders config.json, the workloads with their STALWART_ROLE,
|
|
# the Services, the NetworkPolicies, the Ingress and a cert-manager
|
|
# Certificate. Everything else is operator work in the WebUI, and the values
|
|
# below say for each block whether it configures Stalwart or only Kubernetes.
|
|
# A value that only describes something to Kubernetes is marked as such rather
|
|
# than left to look like application configuration.
|
|
|
|
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
|
# every StatefulSet with replicas: 0 and takes precedence over every
|
|
# nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay
|
|
# in place, so no certificate is re-issued when you scale back up.
|
|
scaleDown: false
|
|
|
|
# Chart-wide default for the limit factor of every node group. A group may
|
|
# override it as nodes.<group>.resources.limitFactor, which always wins. Memory
|
|
# and ephemeral-storage limits are computed as limitFactor x the request; a CPU
|
|
# limit is only set when configured explicitly.
|
|
limitFactor: 3
|
|
|
|
secrets:
|
|
# op:// path of the 1Password item carrying the data-store password in the
|
|
# field "password".
|
|
#
|
|
# ONLY the password. The username cannot live here, and that is an upstream
|
|
# constraint rather than a choice: on the DataStore object authSecret has the
|
|
# type SecretKeyOptional (which has an EnvironmentVariable variant, and that
|
|
# is what this chart uses) while authUsername is a plain String with no
|
|
# secret form at all. So the username is the public value
|
|
# dataStore.postgres.username below and ends up in the ConfigMap, while the
|
|
# password never does.
|
|
dataStoreSecretRef: null
|
|
|
|
# op:// path of the 1Password item carrying the field "recovery-admin" with
|
|
# the COMPLETE value of STALWART_RECOVERY_ADMIN, i.e. "admin:<password>" in
|
|
# one string — the same shape as valkey's auth-conf field. The chart does not
|
|
# assemble it from two fields, because a half-built credential ("admin:" with
|
|
# an empty password) is accepted by the server as a working back door.
|
|
#
|
|
# Why it is worth pinning at all: without it Stalwart generates a temporary
|
|
# administrator password on every bootstrap or recovery start and prints it to
|
|
# stdout — in Kubernetes that means a fresh password in the pod log on every
|
|
# restart, visible to anyone who can read logs, and lost as soon as it
|
|
# scrolls. Set to null to switch the variable off entirely once a permanent
|
|
# administrator exists in the database; that is the documented end state, see
|
|
# NOTES.txt.
|
|
recoveryAdminSecretRef: null
|
|
|
|
# op:// path of the 1Password item for the S3 blob store, carrying the fields
|
|
# "access-key" and "secret-key".
|
|
#
|
|
# This chart does NOT configure the blob store — that object lives in the
|
|
# database. What it does is expose both fields to the container as the
|
|
# environment variables STALWART_S3_ACCESS_KEY and STALWART_S3_SECRET_KEY, so
|
|
# that the BlobStore object you create in the WebUI can reference them
|
|
# instead of storing the credentials in the database:
|
|
# accessKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_ACCESS_KEY"}
|
|
# secretKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_SECRET_KEY"}
|
|
# Both fields accept that variant (accessKey is PublicStringOptional,
|
|
# secretKey is SecretKeyOptional; both offer Value, EnvironmentVariable and
|
|
# File). Leave null when the blob store is not S3 or when you prefer to keep
|
|
# the credentials in the database — the variables are then not set and
|
|
# nothing references them.
|
|
s3SecretRef: null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config.json — the ONLY part of Stalwart this chart actually configures
|
|
# ---------------------------------------------------------------------------
|
|
dataStore:
|
|
postgres:
|
|
# REQUIRED, no default (enforced by required() in the template).
|
|
host: null
|
|
port: 5432
|
|
database: stalwart
|
|
# Public on purpose — see secrets.dataStoreSecretRef above for why this is
|
|
# not a secret reference. It is rendered into the ConfigMap.
|
|
username: stalwart
|
|
tls:
|
|
# Rendered as the object's "useTls" field. The names differ because this
|
|
# chart groups the two TLS switches that upstream keeps flat; the mapping
|
|
# is tls.enable -> useTls and tls.allowInvalidCerts -> allowInvalidCerts.
|
|
#
|
|
# Note the default is the OPPOSITE of upstream's (false). A mail server's
|
|
# data store carries every message's metadata, and this chart would
|
|
# rather fail to connect to a plaintext-only Postgres than reach it
|
|
# silently unencrypted.
|
|
enable: true
|
|
# Accepting an invalid certificate turns TLS into obfuscation: the
|
|
# connection is encrypted against a passive listener and wide open to an
|
|
# active one. Only ever a temporary diagnostic.
|
|
allowInvalidCerts: false
|
|
poolMaxConnections: 10
|
|
# MILLISECONDS, as an integer — not "15s".
|
|
#
|
|
# This is not a styling preference. Stalwart's object encoding states it
|
|
# outright: "A duration is an integer count of milliseconds, and a size is
|
|
# an integer count of bytes. Neither accepts a string."
|
|
# (https://stalw.art/docs/configuration/object-encoding). The DataStore
|
|
# reference gives this field as "Duration? default: 15000". A config.json
|
|
# carrying "15s" is rejected by the server at startup, so the key is named
|
|
# for its unit instead of accepting a suffix the file cannot hold.
|
|
timeoutMs: 15000
|
|
# Extra libpq connection options, passed through verbatim as the object's
|
|
# "options" field. null omits the field.
|
|
options: null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# First start: Stalwart needs recovery mode when config.json is provided
|
|
# ---------------------------------------------------------------------------
|
|
bootstrap:
|
|
# Sets STALWART_RECOVERY_MODE=1 on every node group.
|
|
#
|
|
# WHY THIS EXISTS, AND WHY IT IS NOT OPTIONAL ON A FIRST INSTALL. The setup
|
|
# wizard everyone reaches for runs in BOOTSTRAP mode, and bootstrap mode is
|
|
# entered only when there is no config.json: "The first time Stalwart is
|
|
# started without a config.json, it enters bootstrap mode"
|
|
# (https://stalw.art/docs/configuration/bootstrap-mode). This chart always
|
|
# renders one, so the wizard never appears. Against an empty database that
|
|
# leaves a server with no listeners, no domain and no administrator, and
|
|
# nothing to fix it with.
|
|
#
|
|
# The documented path for exactly this case is recovery mode: "template
|
|
# config.json onto the host, start Stalwart once with STALWART_RECOVERY_MODE=1
|
|
# and a STALWART_RECOVERY_ADMIN supplied from a secrets manager, run
|
|
# stalwart-cli apply against the recovery listener to load the initial
|
|
# configuration, and then restart Stalwart without the recovery variables"
|
|
# (https://stalw.art/docs/configuration/declarative-deployments). Recovery
|
|
# mode brings up the management HTTP endpoint on port 8080 — the WebUI
|
|
# included — and keeps every mail port closed.
|
|
#
|
|
# So: true for the first install, then false and upgrade again. Leaving it on
|
|
# is not a small untidiness — the mail ports stay shut while the Services and
|
|
# the Ingress look perfectly healthy, which is the loudest-looking silent
|
|
# failure this chart has. NOTES.txt prints a warning for as long as it is on.
|
|
enabled: false
|
|
# Log verbosity while recovery mode is on (STALWART_RECOVERY_MODE_LOG_LEVEL).
|
|
# Has no effect once bootstrap.enabled is false.
|
|
logLevel: info
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ports. KUBERNETES ONLY — these numbers do not reach Stalwart.
|
|
# ---------------------------------------------------------------------------
|
|
# THE DUPLICATION BELOW CANNOT BE AVOIDED, SO IT IS WRITTEN DOWN INSTEAD.
|
|
#
|
|
# Every listener Stalwart binds is a NetworkListener object in the database.
|
|
# This chart cannot create those objects, so containerPort here is a second
|
|
# copy of a decision that is really made in the WebUI. If the two disagree, the
|
|
# Service points at a port nothing listens on: connections are refused or time
|
|
# out, every pod stays Ready, and no object reports a problem. Check both sides
|
|
# whenever you touch one.
|
|
#
|
|
# TWO NUMBERS PER ENTRY, AND THEY ARE NOT INTERCHANGEABLE:
|
|
# containerPort — what the application binds inside the pod, and what the
|
|
# NetworkListener object in the database must say.
|
|
# nodePort — what the cluster publishes on every node, and what the HA
|
|
# proxy in front of the cluster connects to.
|
|
# The Service maps one onto the other: nodePort 30025 -> targetPort 25.
|
|
#
|
|
# WHY THE APPLICATION BINDS THE ORDINARY 25/465/993. An earlier draft had it
|
|
# bind 30025/30465/30993 so that nothing privileged was ever bound and the
|
|
# NET_BIND_SERVICE capability could be dropped. That reasoning collapsed: the
|
|
# image ships "setcap cap_net_bind_service=+ep" on the binary, which makes it a
|
|
# capability-dumb binary, and the kernel then fails the execve ITSELF when the
|
|
# bounding set does not carry the capability — before the program binds
|
|
# anything. The capability has to be granted either way (see securityContext
|
|
# below), so high ports bought no hardening at all. What the ordinary numbers
|
|
# buy instead is predictability: the listener objects an operator creates in
|
|
# the WebUI carry the numbers from every page of Stalwart's documentation and
|
|
# from the image's own EXPOSE list, which takes most of the surprise out of the
|
|
# duplication above.
|
|
#
|
|
# The nodePorts must be inside the API server's --service-node-port-range
|
|
# (default 30000-32767). 30025/30465/30993 are, with the default — UNVERIFIED
|
|
# against this cluster's actual setting.
|
|
#
|
|
# Only these three plus HTTP are published. 587, 143, 110, 995 and 4190 are
|
|
# deliberately absent and can be added later without a breaking change: add an
|
|
# entry here and list it in the groups that should answer on it.
|
|
ports:
|
|
smtp:
|
|
containerPort: 25
|
|
nodePort: 30025
|
|
protocol: TCP
|
|
public: true
|
|
submissions:
|
|
containerPort: 465
|
|
nodePort: 30465
|
|
protocol: TCP
|
|
public: true
|
|
imaps:
|
|
containerPort: 993
|
|
nodePort: 30993
|
|
protocol: TCP
|
|
public: true
|
|
http:
|
|
# The management/JMAP/WebUI port, and the port the probes use. Never
|
|
# published as a NodePort: it is reached through the Ingress (ClusterIP
|
|
# Service) and by the kubelet.
|
|
containerPort: 8080
|
|
protocol: TCP
|
|
public: false
|
|
|
|
# The init container that seeds /etc/stalwart/config.json out of the ConfigMap.
|
|
# Pinned here rather than hardcoded in the template, and reviewed on every
|
|
# app-update round, because it is an auxiliary image and its tag does not come
|
|
# from appVersion (README, guideline 3).
|
|
initImage:
|
|
repository: docker.io/library/busybox
|
|
tag: "1.38.0-uclibc"
|
|
|
|
service:
|
|
# Cluster, and Local is not an option here — this is a property of the
|
|
# deployment's topology, not a preference.
|
|
#
|
|
# externalTrafficPolicy: Local only forwards to pods on the node that
|
|
# received the packet. Mail enters through an HA proxy that distributes
|
|
# 25/465/993 across the three control-plane nodes, and no Stalwart pod is
|
|
# ever scheduled on a control-plane node — so under Local EVERY packet would
|
|
# be dropped, not some.
|
|
#
|
|
# The price of Cluster is that kube-proxy masquerades the source address to
|
|
# the receiving node's IP (it has to: the reply would otherwise leave through
|
|
# a different node than the one the sender holds its connection with). For a
|
|
# mail server a lost source address is not cosmetic; it switches off SPF,
|
|
# takes DMARC with it, makes every blocklist report "clean", collapses
|
|
# per-IP rate limits onto one address, removes greylisting's key, and makes
|
|
# the auto-ban lock out every sender at once by banning the node IP. The
|
|
# Received: chain becomes worthless, which is the one item on that list that
|
|
# destroys evidence retroactively instead of just failing.
|
|
#
|
|
# What pays that price back is the PROXY protocol, and only because of this
|
|
# topology: the HA proxy terminates the inbound connection and opens its OWN
|
|
# to the node, so it can prepend a PROXY v2 header on that connection — for
|
|
# port 25 as well, where no remote mail server would ever send one. The real
|
|
# client address then travels in the header and the masquerading stops
|
|
# mattering. This is a MANDATORY companion step, not an optimisation; the
|
|
# wiki page has the all-or-nothing warning that goes with it.
|
|
externalTrafficPolicy: Cluster
|
|
# Extra annotations for every Service this chart renders (ClusterIP and
|
|
# NodePort alike). Free-form by nature.
|
|
annotations: {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node groups. One StatefulSet, one headless Service and one NetworkPolicy each.
|
|
# ---------------------------------------------------------------------------
|
|
# A MAP, NOT A LIST, and on purpose: with a list every override would be
|
|
# index-based (--set nodes[1].replicas=2), so inserting a group later would
|
|
# silently repoint every existing override at the wrong group.
|
|
#
|
|
# The key is the cluster role name and goes into STALWART_ROLE. A ClusterRole
|
|
# object of exactly that name has to exist in the database — see the fail-open
|
|
# warning below, which is the most important paragraph in this file.
|
|
#
|
|
# THERE IS NO nodes.<group>.tasks KEY. Which tasks and listeners a role runs is
|
|
# a property of the ClusterRole object in the database, and this chart cannot
|
|
# write it. A values key that looked like it set them and did not would be
|
|
# worse than its absence.
|
|
#
|
|
# FAIL-OPEN: A NODE WHOSE ROLE IS MISSING FROM THE DATABASE BECOMES A FULL NODE.
|
|
#
|
|
# From the source, not the documentation. The bootstrap registry looks a role
|
|
# up by name; when it finds none it records a non-fatal error and leaves the
|
|
# role unset, and the listener check then reads
|
|
# bp.role.as_ref().is_none_or(|r| ...)
|
|
# which yields true for EVERY listener when the role is None. The same holds
|
|
# for the task set. So a typo in a group name here, or a rename here without
|
|
# the matching rename in the database, turns that group into a node that binds
|
|
# everything — the mx group would serve IMAP and the admin interface. The pod
|
|
# is Running, the log holds one line ('Cluster role "mx" not found in
|
|
# registry'), and the readiness probe is GREEN. Kubernetes cannot detect it.
|
|
#
|
|
# Three things in this chart contain that failure, and they are the reason the
|
|
# layout looks the way it does: mx ships with replicas: 0, the per-port
|
|
# Services publish only what was asked for, and the NetworkPolicy is a ceiling
|
|
# on reachability that holds no matter which ports the process actually bound.
|
|
# Documented as a trap rather than explained away.
|
|
nodes:
|
|
# Everything: all tasks, all listeners. The group the Ingress points at.
|
|
full:
|
|
# 0 is allowed and scales this group down; the chart-global scaleDown wins.
|
|
replicas: 1
|
|
# Which ports this group may be reached on. NOT a copy of the listener
|
|
# configuration in the database but a statement of its own: "this group is
|
|
# reachable here and nowhere else". That is why a disagreement in either
|
|
# direction is harmless — a role that binds more is unreachable on the
|
|
# extra ports, and a policy that permits more has nothing listening — and
|
|
# it is exactly what takes the edge off the fail-open above.
|
|
#
|
|
# Every group needs http, including mx: it is the port all three probes
|
|
# use. It is not published for mx.
|
|
exposedPorts: [smtp, submissions, imaps, http]
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Distribute THIS GROUP's pods over distinct nodes (Gitea #96)
|
|
# -----------------------------------------------------------------------
|
|
# Renders a topologySpreadConstraints entry on topologyKey
|
|
# kubernetes.io/hostname with maxSkew: 1 and a labelSelector the CHART
|
|
# fills in — the two labels it puts on its own pods anyway. You never
|
|
# write that selector, and that is the point: a spread constraint whose
|
|
# selector matches no pod is TRIVIALLY SATISFIED. It distributes nothing,
|
|
# the rollout is green, and nothing anywhere says so.
|
|
#
|
|
# ONLY WITHIN THIS GROUP. The selector names the release AND the node
|
|
# group, so "full" pods are spread against "full" pods and "mx" pods
|
|
# against "mx" pods. A "full" and an "mx" pod on the same node is
|
|
# deliberately fine — they are different roles, not redundant copies.
|
|
#
|
|
# RENDERED ONLY FROM TWO EFFECTIVE REPLICAS UPWARDS. With one pod any
|
|
# placement satisfies it, so it would describe nothing; the count used is
|
|
# the computed one, so scaleDown: true removes it too.
|
|
#
|
|
# whenUnsatisfiable IS DoNotSchedule, i.e. THIS IS A PROMISE AND NOT AN
|
|
# EFFORT — and the price is a pod that stays Pending rather than one that
|
|
# lands somewhere useless. ScheduleAnyway was the original proposal and was
|
|
# rejected: with one worker at 97 % requested CPU the scheduler may put both
|
|
# replicas of a group on the same remaining node, entirely within the rule
|
|
# and with the redundancy gone. The cases where the promise costs something:
|
|
#
|
|
# - TWO OF THREE WORKERS GONE: the second replica stays Pending instead
|
|
# of joining the survivor — but only WHILE THE Node OBJECTS STILL
|
|
# EXIST. A NotReady node still counts as an (empty) domain, so the
|
|
# third replica would raise the skew to 2. Delete the Node objects and
|
|
# the domains disappear with them and the pod schedules. Same
|
|
# mechanism, opposite outcome, one step apart.
|
|
# - A NARROWING nodeSelector/affinity BELOW: see nodeSelector further
|
|
# down. That combination blocks itself, deliberately and loudly.
|
|
#
|
|
# A Pending pod with no scheduling event reads like a different problem
|
|
# than a distribution rule, so NOTES.txt names this rule whenever it is
|
|
# rendered.
|
|
#
|
|
# THE FIELD THAT CAN BE DROPPED WITHOUT A WORD: the constraint carries
|
|
# nodeAffinityPolicy: Ignore, and that field is BETA — on by default since
|
|
# Kubernetes 1.26, graduated to GA with the feature gate
|
|
# NodeInclusionPolicyInPodTopologySpread in 1.33. On an API server older
|
|
# than 1.26, or one with the gate switched off, the field is pruned
|
|
# SILENTLY, and the gap it was set against comes straight back (the
|
|
# nodeSelector case below). Nothing reports it: the object still looks
|
|
# correct in `kubectl get sts -o yaml`, minus one line. So 1.26 is the
|
|
# minimum version this chart can make the promise on at all. NOT MEASURED
|
|
# against an older server — this is the documented pruning behaviour for a
|
|
# field behind a disabled gate, not something tried here. Check the
|
|
# rendered StatefulSet against the live one if the server is near that
|
|
# boundary.
|
|
#
|
|
# WHAT IS *NOT* A CONSEQUENCE OF THAT FIELD, because the two
|
|
# node-inclusion policies are not a package: the ceiling on replicas
|
|
# described at nodeSelector below comes from nodeTaintsPolicy, which this
|
|
# chart leaves at its own default. It was there before nodeAffinityPolicy
|
|
# was set and is not its price.
|
|
#
|
|
# ONE PRECONDITION, AND IT IS NOT IN THIS FILE: this works because the
|
|
# StatefulSet has NO volumeClaimTemplates. Open measurement 1 on #65 asks
|
|
# whether anything under /var/lib/stalwart has to survive a restart; if
|
|
# that answer ever becomes yes, a PVC per group appears, and with a
|
|
# node-local or topology-bound storage class the pod is pinned to wherever
|
|
# its volume was first provisioned. Distribution is then decided at
|
|
# provisioning time rather than by this rule, and DoNotSchedule can keep a
|
|
# returning pod Pending for good. This rule holds only while the chart
|
|
# stays PVC-free.
|
|
#
|
|
# AND THE COST OF TURNING IT ON BY SCALING UP: going from 1 to 2 replicas
|
|
# changes the POD TEMPLATE (the constraint appears in it), so the existing
|
|
# pod is replaced in the same rollout round rather than merely joined by a
|
|
# second one. Expect a restart of the running node, not just an addition.
|
|
spreadAcrossNodes: true
|
|
|
|
# A PodDisruptionBudget for this group, rendered only while the group's
|
|
# COMPUTED replica count is >= 2 — the spread constraint's companion: that
|
|
# one decides the two pods sit on different nodes, this one decides only
|
|
# one of those nodes may be emptied at a time.
|
|
#
|
|
# The condition is not tidiness. minAvailable: 1 with exactly one replica
|
|
# forbids every voluntary eviction, so `kubectl drain` blocks forever on a
|
|
# pod nothing may move — and the message names the PDB rather than the
|
|
# replica count, which sends the reader to the wrong file. Precedent and
|
|
# full argument: keycloak (README, guideline 11).
|
|
podDisruptionBudget:
|
|
enabled: true
|
|
# minAvailable, not maxUnavailable: the promise is about the service —
|
|
# at least one pod of this group keeps answering through a drain.
|
|
minAvailable: 1
|
|
|
|
# Generic pass-through into this group's pod spec. Rendered verbatim with
|
|
# toYaml; the chart reads none of their contents, which is why they are
|
|
# loosely typed in values.schema.json — Kubernetes owns their schema. An
|
|
# empty value renders no field at all.
|
|
#
|
|
# THE ONE COMBINATION THAT BLOCKS ITSELF, and it does so on purpose:
|
|
# narrowing the eligible nodes below this group's replica count while
|
|
# spreadAcrossNodes is on leaves a replica permanently Pending. With
|
|
# nodeAffinityPolicy: Ignore on the chart's constraint, a nodeSelector
|
|
# down to one node gives 2,0,0 — skew 2 — rather than the one eligible
|
|
# domain and skew 0 it would give under the API default of Honor. The
|
|
# second shape is what we do NOT want: both pods co-located, nothing
|
|
# Pending, no event, and a selector that is perfectly correct, so no check
|
|
# in CI can see it either. Loud beats silent; that is the whole trade.
|
|
#
|
|
# AND THE CEILING, WHICH IS A RULE AND NOT A NUMBER. The chart leaves
|
|
# nodeTaintsPolicy at its API default of Ignore, so EVERY node counts as a
|
|
# domain — control-plane nodes included, and no Stalwart pod ever runs
|
|
# there. Those domains hold the minimum at 0 permanently, so with
|
|
# maxSkew: 1 a group can never place more replicas than there are
|
|
# SCHEDULABLE WORKERS. Add a worker and the ceiling rises with it; remove
|
|
# one and it falls. Do not write today's count down here — it would be
|
|
# wrong at the next cluster rebuild and nobody would come back for it.
|
|
# Raising a group past that ceiling needs spreadAcrossNodes: false, or an
|
|
# own topologySpreadConstraints entry with ScheduleAnyway.
|
|
affinity: {}
|
|
nodeSelector: {}
|
|
tolerations: []
|
|
# Appended AFTER the chart's own constraint and really AND-ed with it, so
|
|
# a second key — topology.kubernetes.io/zone, say — works as expected.
|
|
#
|
|
# An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule
|
|
# (or omitted, which IS DoNotSchedule) ABORTS THE RENDER naming both this
|
|
# value and spreadAcrossNodes. Kubernetes keys a constraint on exactly
|
|
# that pair: `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 — one labelSelector is dropped
|
|
# and the result is green. A named abort is better than either.
|
|
topologySpreadConstraints: []
|
|
resources:
|
|
requests:
|
|
cpu: 200m
|
|
memory: 512Mi
|
|
ephemeral-storage: 64Mi
|
|
# Additive environment entries for this group's container, appended to the
|
|
# ones the chart sets (STALWART_ROLE, STALWART_HOSTNAME and the secret-fed
|
|
# ones). Supports the value, secretKeyRef and configMapKeyRef forms and is
|
|
# rendered through tpl.
|
|
#
|
|
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
|
# does not replace it. At install both reach the pod and the later one wins;
|
|
# on an EXISTING release Kubernetes merges env by name, the pair collapses
|
|
# to the chart's own value, and helm upgrade still reports success. Do not
|
|
# use this to override STALWART_ROLE.
|
|
env: []
|
|
# SMTP intake only. Add the matching ClusterRole in the database first:
|
|
# {"name":"mx","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableSome", ...}}
|
|
mx:
|
|
# Zero until the role "mx" exists in the database. Raising this before the
|
|
# role is there does not fail — it produces a second full node (see
|
|
# fail-open above), which is the opposite of what this group is for.
|
|
replicas: 0
|
|
exposedPorts: [smtp, http]
|
|
# Same six keys as nodes.full, same defaults, and argued in full there —
|
|
# including the three things that are easy to get wrong: that
|
|
# nodeAffinityPolicy can be pruned silently below Kubernetes 1.26, that the
|
|
# replica ceiling is "as many as there are schedulable workers" rather than
|
|
# a number, and that the ceiling comes from nodeTaintsPolicy and not from
|
|
# the field this chart sets.
|
|
#
|
|
# Neither object is rendered for this group while replicas is 0, and the
|
|
# condition is on the computed count: raising it to 2 (after the "mx"
|
|
# ClusterRole exists — see above) brings both the spread constraint and the
|
|
# PodDisruptionBudget with it, independently of what "full" does.
|
|
spreadAcrossNodes: true
|
|
podDisruptionBudget:
|
|
enabled: true
|
|
minAvailable: 1
|
|
affinity: {}
|
|
nodeSelector: {}
|
|
tolerations: []
|
|
topologySpreadConstraints: []
|
|
resources:
|
|
requests:
|
|
cpu: 100m
|
|
memory: 256Mi
|
|
ephemeral-storage: 64Mi
|
|
env: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NetworkPolicy — the ceiling on reachability, independent of what bound what
|
|
# ---------------------------------------------------------------------------
|
|
networkPolicy:
|
|
# On by default. Only a real boundary where the cluster's CNI enforces
|
|
# NetworkPolicies; an unenforced policy is accepted by the API server and
|
|
# filters nothing.
|
|
enabled: true
|
|
# Source CIDRs allowed to reach the http port, in addition to
|
|
# allowedNamespaces. This is where the kubelet's probe traffic has to be
|
|
# permitted: probes come from the IP of the WORKER node running the pod, an
|
|
# address this chart cannot know.
|
|
#
|
|
# EMPTY MEANS OPEN, not blocked. With no entry the http rule is rendered
|
|
# without a "from" selector, so the port is reachable from anywhere the CNI
|
|
# routes to. That is the deliberate choice between two bad defaults: a
|
|
# guessed CIDR that excludes the real node addresses blocks every probe, and
|
|
# a pod that never becomes Ready reads as a broken application rather than as
|
|
# a policy. http is not a NodePort, so "open" here means open inside the
|
|
# cluster, not from the internet. Narrow it by listing the worker node IPs
|
|
# (e.g. ["10.0.1.11/32", "10.0.1.12/32"]).
|
|
#
|
|
# And mind the companion trap: once this list is non-empty the http rule is
|
|
# restricted, which cuts off everything else that needs the port. The chart
|
|
# adds the release's own namespace, ingressController below and
|
|
# allowedNamespaces to the same rule for exactly that reason.
|
|
probeSources: []
|
|
# Namespaces allowed to reach the http port, matched on the
|
|
# kubernetes.io/metadata.name label Kubernetes sets on every namespace
|
|
# itself. The release's own namespace is always allowed. A name that does not
|
|
# exist matches nothing and is silently ineffective.
|
|
#
|
|
# NOT FOR THE INGRESS CONTROLLER - use ingressController below. This value is
|
|
# namespace-granular, so naming the controller's namespace here would open
|
|
# the http port to EVERY pod in it (kube-system, in this cluster). The
|
|
# earlier version of this comment advised exactly that, with
|
|
# ["ingress-nginx"] as the example, and both halves of that advice are now
|
|
# wrong: see ingressController.
|
|
allowedNamespaces: []
|
|
# The ingress controller allowed to reach the http port once probeSources or
|
|
# allowedNamespaces restricts it. With neither set the http rule carries no
|
|
# source selector at all and the controller reaches the port anyway, so this
|
|
# value only becomes load-bearing at the moment one of them is filled - which
|
|
# is also the moment the Ingress would otherwise go dark.
|
|
#
|
|
# The two keys are rendered as ONE "from" entry, which makes them an AND:
|
|
# pods carrying podLabels IN namespace. That is the whole point of having
|
|
# them separate from allowedNamespaces.
|
|
#
|
|
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is GONE -
|
|
# no DaemonSet, no pods - the only IngressClass is "traefik" and it is the
|
|
# default, and the controller is the DaemonSet rke2-traefik in kube-system.
|
|
# The same Traefik serves the Gateway API here as well. A policy written
|
|
# against the wrong controller is accepted, looks strict, and silently blocks
|
|
# all web traffic; ingress.className says nothing about it, because a class
|
|
# names an implementation and not the pods the traffic comes from.
|
|
ingressController:
|
|
namespace: kube-system
|
|
podLabels:
|
|
app.kubernetes.io/name: rke2-traefik
|
|
|
|
# The policy's INBOUND side. Mind the three names that overlap as substrings,
|
|
# because a search for "ingress" matches all of them and they are different
|
|
# things:
|
|
#
|
|
# ingress (top level) the Ingress OBJECT this chart
|
|
# renders - host, TLS, className
|
|
# networkPolicy.ingress this block: what the policy lets IN
|
|
# networkPolicy.ingressController the SOURCE SELECTOR for the ingress
|
|
# controller's pods. A SIBLING of this block
|
|
# and NO PART OF IT, despite the name.
|
|
ingress:
|
|
# Opens the pod for Istio's ambient mesh (Gitea #143). OFF by default and
|
|
# additive: while it is false this chart renders byte for byte as it did
|
|
# before the value existed.
|
|
#
|
|
# IT RENDERS TWO THINGS, and that they come together is the point:
|
|
#
|
|
# 1. 15008/TCP inbound - ztunnel's HBONE tunnel. Every inbound mesh
|
|
# connection arrives on it, and ambient "does not and will never bypass
|
|
# Kubernetes NetworkPolicy enforcement", so without this port a pod
|
|
# whose namespace has joined the mesh is unreachable.
|
|
# 2. 169.254.7.127/32 as an additional peer - but ONLY where the rule
|
|
# restricts sources at all, which here means probeSources is non-empty.
|
|
# Inside the mesh the kubelet's probes are redirected and no longer come
|
|
# from the worker node's address.
|
|
#
|
|
# WHY ONE SWITCH AND NOT TWO: point 2 is the one nobody thinks of. Whoever
|
|
# adds "15008" to a port list by hand writes a policy that passes review, and
|
|
# the first time probeSources is narrowed every probe is dropped - the pod
|
|
# never becomes Ready and the symptom reads as a broken application rather
|
|
# than as a policy. A switch whose halves can only be had together cannot be
|
|
# set halfway, which is why this is a named intention and not a port list.
|
|
#
|
|
# TWO THINGS IT DOES NOT PROVE:
|
|
# - That 15008 alone is enough. That comes from Istio's documentation;
|
|
# measured on this cluster is only which listeners ztunnel opens.
|
|
# Provable at the first namespace to join the mesh - none carries
|
|
# istio.io/dataplane-mode yet.
|
|
# - That the probe-source half works. probeSources is empty in every chart
|
|
# here, so no release reaches that branch. CI proves its render, not its
|
|
# effect.
|
|
#
|
|
# Note where the port lands: it joins the rule that carries the probe port,
|
|
# so once probeSources restricts that rule, 15008 is restricted with it.
|
|
# Whether ztunnel needs a peer of its own is open - it runs with its own pod
|
|
# IPs in istio-system (measured: no hostNetwork on the DaemonSet, Istio
|
|
# 1.30.6), so if one proves necessary it is expressible as a
|
|
# namespaceSelector plus podSelector and needs no unknowable node CIDR.
|
|
# BOTH NUMBERS BELOW ARE ISTIO CONSTANTS, NOT STANDARDS, and they are values
|
|
# rather than template literals for exactly that reason: a vendor constant
|
|
# can change, and an operator should be able to correct it without waiting
|
|
# for a chart release. Their provenance is written on each one.
|
|
#
|
|
# DELETING THIS OBJECT IS SAFE. Every read falls back to the defaults
|
|
# declared here (#99), so "ambientMesh: null", or an object carrying only
|
|
# "enabled: true", renders the documented defaults instead of failing.
|
|
ambientMesh:
|
|
# The switch. Everything else here is inert while it is false, and the
|
|
# chart renders exactly as it did before the object existed.
|
|
enabled: false
|
|
|
|
# ISTIO'S HBONE PORT. A constant Istio chose, not a standard: not
|
|
# registered with IANA for this, prescribed by no protocol, and unknown to
|
|
# Kubernetes. Read for ztunnel 1.30.6 (measured on this cluster: ztunnel
|
|
# opens [::]:15008 as its inbound listener).
|
|
#
|
|
# If Istio changes it, a pod in the mesh cannot be reached. Change it here
|
|
# in all 30 charts AND in the pinned expectation inside
|
|
# ci/run-networkpolicy-test.sh - the test is deliberately what forces
|
|
# someone to look, because the exception it grants this port is pinned to
|
|
# the number IT knows and not to whatever this value says. A value-driven
|
|
# exception would let a mistyped port through, which is the hole design A
|
|
# of #143 was rejected for.
|
|
#
|
|
# NOT MEASURED: that this port ALONE suffices comes from Istio's
|
|
# documentation. Measured here is only which listeners ztunnel opens, and
|
|
# no namespace carries istio.io/dataplane-mode yet.
|
|
port: 15008
|
|
|
|
# SOURCES THE KUBELET'S PROBES ARRIVE FROM ONCE ISTIO HAS REDIRECTED THEM.
|
|
#
|
|
# NOT THE SAME VALUE AS networkPolicy.probeSources one level up, and the
|
|
# two are easy to confuse, so:
|
|
#
|
|
# networkPolicy.probeSources
|
|
# YOUR worker node CIDRs. Empty by default, and empty means the
|
|
# probe rule is OPEN. The chart cannot know these addresses.
|
|
# networkPolicy.ingress.ambientMesh.redirectedProbeSources
|
|
# ISTIO's rewrite address. Shipped filled, because unlike the node
|
|
# IPs it is a constant the chart CAN know. Only rendered where the
|
|
# rule above is restricted, i.e. where probeSources is non-empty.
|
|
#
|
|
# WHERE 169.254.7.127 COMES FROM - vendor magic, not protocol:
|
|
#
|
|
# - LINK-LOCAL, 169.254.0.0/16 (RFC 3927): a range reserved for
|
|
# node-local use that is never routed - exactly the range one picks a
|
|
# vendor's magic address out of.
|
|
# - Istio's IPv6 counterpart gives it away:
|
|
# fd16:9254:7127:1337:ffff:ffff:ffff:ffff carries the SAME DIGITS
|
|
# (9254:7127 is 169.254.7.127) plus 1337. Numbers built that way are
|
|
# vendor constants, not protocols.
|
|
# - KUBERNETES DOES NOT KNOW THIS ADDRESS. Neither the NetworkPolicy API
|
|
# nor the kubelet has any notion of it; only Istio's own data plane
|
|
# rewrites the probe source onto it.
|
|
#
|
|
# Read for ztunnel 1.30.6. IF ISTIO CHANGES IT, THE FAILURE IS SILENT:
|
|
# probes would be dropped, and only where probeSources is non-empty - which
|
|
# is nowhere in this repository today - so nothing here and nothing in
|
|
# Kubernetes would report it. What to re-check then: the address Istio's
|
|
# release notes name for probe redirection, this list in all 30 charts, and
|
|
# the pinned expectation in ci/run-networkpolicy-test.sh.
|
|
#
|
|
# A LIST, not a string, and the one case where that does not contradict "a
|
|
# constant, not a configuration": on a DUAL-STACK cluster BOTH entries are
|
|
# needed at the same time, not one of two. This cluster is SingleStack
|
|
# IPv4, so the IPv6 counterpart is left out - an ipBlock of the wrong
|
|
# family is accepted by the API server and matches nothing. Add it
|
|
# ALONGSIDE, never instead, if the cluster gains IPv6.
|
|
#
|
|
# Clearing this list restores the default rather than removing the ipBlock,
|
|
# deliberately: the two halves of this switch are not separable. To not
|
|
# open the port at all, set enabled: false.
|
|
#
|
|
# UNPROVEN IN OPERATION: probeSources is empty in every chart here, so no
|
|
# release reaches this branch. CI proves the render, not the effect.
|
|
redirectedProbeSources:
|
|
- 169.254.7.127/32
|
|
|
|
# Egress is restricted BY PORT, not by destination, and the reason is a limit
|
|
# of the object rather than laziness: a NetworkPolicy matches ipBlock CIDRs
|
|
# and pod/namespace selectors, never hostnames — so dataStore.postgres.host
|
|
# and externalServices.*.host cannot appear in it. Each rule below therefore
|
|
# permits its port to any destination. What it still buys is real: a
|
|
# compromised pod cannot open arbitrary ports outbound.
|
|
#
|
|
# Outbound SMTP is the one that must stay wide: delivering mail means
|
|
# connecting to port 25 on any host on the internet.
|
|
egress:
|
|
# DNS (UDP and TCP 53) to any destination. Switching this off breaks
|
|
# everything, including MX lookups and SPF.
|
|
dns: true
|
|
# Outbound SMTP on port 25 to any destination — required for delivery.
|
|
# Deliberately NOT derived per group from the outboundMta task: this chart
|
|
# does not know which tasks a role runs, the value would drift away from
|
|
# the database, and the failure mode would be mail silently not leaving.
|
|
# So it is uniform for every group.
|
|
smtp: true
|
|
# Additional outbound TCP ports, beyond the data store, S3 and Valkey ports
|
|
# the chart already derives from the values above. Add 443 here if the
|
|
# deployment needs outbound HTTPS (ACME over DNS-01 through a provider API,
|
|
# an AI model endpoint, a remote lookup list).
|
|
extraPorts: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# External services — KUBERNETES ONLY. These do not configure Stalwart.
|
|
# ---------------------------------------------------------------------------
|
|
# The blob store and the in-memory store are objects in the database and are
|
|
# created in the WebUI. What the chart uses these values for is exactly three
|
|
# things, and nothing else:
|
|
# - the port goes into the egress rule of every NetworkPolicy,
|
|
# - the valkey host decides whether the in-memory store has an address at all
|
|
# (see the fail below), and
|
|
# - both hosts are printed in NOTES.txt as the addresses to enter in the
|
|
# WebUI, so the installation checklist is not a separate document.
|
|
# Setting a host here does not point Stalwart at it.
|
|
externalServices:
|
|
s3:
|
|
host: ""
|
|
port: 443
|
|
valkey:
|
|
# Empty plus valkey.enabled: true resolves to the subchart's Service,
|
|
# "<release>-valkey". Empty with the subchart off and more than one
|
|
# Stalwart pod ABORTS THE RENDER with a message naming both values, because
|
|
# that combination is a cluster without an in-memory store: from v0.16 the
|
|
# coordinator's Default variant "reuses the Redis connection already
|
|
# configured on the in-memory store", so a multi-node install has no
|
|
# coordination path at all. Failing the render is the loud version of a
|
|
# cluster that comes up and then does not agree with itself.
|
|
host: ""
|
|
port: 6379
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TLS for the mail ports — cert-manager, not Stalwart's own ACME
|
|
# ---------------------------------------------------------------------------
|
|
# Why not Stalwart's built-in ACME: the HA proxy forwards 25, 465 and 993, and
|
|
# NOT 80, so an HTTP-01 challenge answered by Stalwart is unreachable from
|
|
# outside. DNS-01 inside Stalwart would work but would duplicate what
|
|
# cert-manager already does in this cluster.
|
|
#
|
|
# Why a certificate is needed at all when there is an Ingress: the Ingress
|
|
# covers only the WebUI and JMAP. The HA proxy terminates no TLS, so Stalwart
|
|
# itself has to present the certificate for STARTTLS on 25 and implicit TLS on
|
|
# 465 and 993.
|
|
certificate:
|
|
enabled: true
|
|
# Cluster issuer for the MAIL certificate. null falls back to
|
|
# ingress.clusterIssuer.
|
|
#
|
|
# MIND THE CHALLENGE TYPE. The same reasoning that rules out Stalwart's ACME
|
|
# rules out an HTTP-01 issuer here: port 80 does not reach this cluster for
|
|
# the mail hostname. A DNS-01 issuer is usually the right answer, and it is
|
|
# usually a DIFFERENT issuer from the one the Ingress uses — which is why
|
|
# this is its own value instead of reusing ingress.clusterIssuer silently.
|
|
clusterIssuer: null
|
|
# Additional DNS names for the mail certificate. ingress.domain is always
|
|
# included, so this is for the hostnames clients and other MTAs actually
|
|
# connect to when they differ from the WebUI host — typically the MX name and
|
|
# the autoconfig names (mail., smtp., imap.).
|
|
extraDnsNames: []
|
|
# Mount path of the issued certificate inside the container. A separate
|
|
# top-level path rather than something under /etc/stalwart, so there is no
|
|
# mount nested inside the config emptyDir.
|
|
#
|
|
# These two files are what the TLS object in the DATABASE has to point at:
|
|
# certificate: /etc/stalwart-tls/tls.crt
|
|
# privateKey: /etc/stalwart-tls/tls.key
|
|
mountPath: /etc/stalwart-tls
|
|
|
|
ingress:
|
|
# Gates the whole Ingress template. ingress.domain stays REQUIRED either way:
|
|
# it is read by the Certificate's dnsNames as well, so switching the Ingress
|
|
# off would otherwise render a certificate with no name in it. Only
|
|
# ingress.clusterIssuer becomes unnecessary — and only if
|
|
# certificate.clusterIssuer is set, since that falls back to it.
|
|
enabled: true
|
|
# REQUIRED, no default.
|
|
clusterIssuer: null
|
|
# REQUIRED, no default.
|
|
domain: null
|
|
className: nginx
|
|
# Which node group the Ingress routes to. Must be a key of nodes: above, and
|
|
# that group must list http in its exposedPorts — both are checked, and the
|
|
# render aborts naming the value. It matters that this is a single group: the
|
|
# WebUI's setup and management screens are not safe to spread over a group
|
|
# that may not run the management tasks, and a Service selecting several
|
|
# groups would send every request to a random one of them.
|
|
nodeGroup: full
|
|
# Extra annotations for the Ingress; merged with the cert-manager
|
|
# cluster-issuer annotation the chart always sets.
|
|
#
|
|
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
|
|
# controller behaviour. For THIS chart that is the most consequential line in
|
|
# the whole block: read the httpRoute section below before switching modes,
|
|
# because what lives in here on the live installation is the CORS
|
|
# configuration the webmail depends on.
|
|
annotations: {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
|
|
# ---------------------------------------------------------------------------
|
|
# Mutually exclusive with ingress.enabled - the render aborts when both are
|
|
# true, and the message names both value paths. The chart renders ONLY an
|
|
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
|
|
# hostname, and no implementation-specific policy object.
|
|
#
|
|
# 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 the ingress.nodeGroup group,
|
|
# through that group's own http Service, and aborts the render if that group
|
|
# does not expose "http" - the same two guards the Ingress has, for the same
|
|
# reason: the WebUI's setup and management screens are not safe to spread over
|
|
# a group that may not run the management tasks.
|
|
#
|
|
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
|
|
# anything does. certificate.enabled renders a Certificate for 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 "cleaned up" as part of the migration.
|
|
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
|
|
# certificate: clients see a name mismatch while every Kubernetes object in the
|
|
# release stays green.
|
|
#
|
|
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
|
|
# routed by the Ingress. They keep reaching the pods through the per-port
|
|
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
|
|
# this chart renders neither, and doing so would be a separate decision.
|
|
#
|
|
# WHAT THIS MODE COSTS - 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):
|
|
#
|
|
# nginx.ingress.kubernetes.io/enable-cors: true
|
|
# nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
|
|
# nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
|
# nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
|
#
|
|
# That origin is BULWARK, the webmail chart in this same group. The CORS
|
|
# configuration is what allows the browser-side webmail to call this server's
|
|
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
|
|
# error in the browser and NO server-side symptom whatsoever.
|
|
#
|
|
# AND "no counterpart" would be the wrong thing to claim here. Measured against
|
|
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
|
|
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
|
|
# inference:
|
|
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
|
|
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
|
|
# Its documented support level, verbatim from that CRD: "Support:
|
|
# Extended".
|
|
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
|
|
# Extended", not Core.
|
|
#
|
|
# There IS a counterpart, then, and this chart still does not render it. Three
|
|
# reasons, in order:
|
|
#
|
|
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
|
|
# - a specific origin, a specific method list. Rendering a filter means
|
|
# inventing a values surface in a sweep PR, and one that would exist in
|
|
# Gateway mode only, since the chart sets no CORS annotations in Ingress
|
|
# mode either. That asymmetry is its own defect and its own piece of work.
|
|
# 2. EXTENDED SUPPORT IS NOT SUPPORT. An implementation that does not support
|
|
# an Extended value reports it in the route's status, and a rejected route
|
|
# is rejected WHOLE - which here would take the WebUI and JMAP down
|
|
# together (the reasoning #119 arrived at for statusCode). The observed
|
|
# failure is quieter still: Cilium accepts filters.type: CORS and
|
|
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
|
|
# i.e. Accepted: True with the behaviour absent. A chart cannot know which
|
|
# implementation it landed on.
|
|
# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
|
|
# ONE HERE. A responseHeaderModifier can add the three
|
|
# Access-Control-Allow-* headers to a response. The PREFLIGHT is a
|
|
# different thing: the browser sends OPTIONS first and the GATEWAY, not
|
|
# the backend, has to answer it - the CORS filter's own contract says a
|
|
# path carrying it MUST NOT pass the preflight upstream. A bare header
|
|
# modifier cannot do that. And this installation's header list includes
|
|
# Authorization, which makes EVERY webmail request preflighted: the half
|
|
# that is expressible without the CORS filter is precisely the half that
|
|
# does not help.
|
|
#
|
|
# So configure CORS AT THE GATEWAY, using that implementation's own CORS
|
|
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
|
|
# curl: curl sends no preflight, so it passes while a browser fails.
|
|
#
|
|
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
|
|
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
|
|
# without them fails at install, so this is a value and not a guess.
|
|
httpRoute:
|
|
enabled: false
|
|
|
|
# The Gateway(s) the route attaches to - a platform object, usually in
|
|
# another namespace, whose listener must allow this namespace in
|
|
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
|
|
# optionally namespace, sectionName, port, group, kind), passed through
|
|
# unchanged:
|
|
#
|
|
# parentRefs:
|
|
# - name: public
|
|
# namespace: gateway
|
|
# sectionName: https
|
|
#
|
|
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
|
# list, because a route without a parent is created, is valid and routes
|
|
# nothing, with no event to say so.
|
|
parentRefs: []
|
|
|
|
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
|
|
# Set explicitly only to publish under a different or an additional name -
|
|
# which is how a migration from the Ingress runs: the HTTPRoute takes a
|
|
# second hostname while the Ingress keeps ingress.domain.
|
|
#
|
|
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
|
|
# bootstrap the WebUI is reached at https://<the configured server
|
|
# hostname>/admin, and no value of this chart changes that - so a second
|
|
# hostname is reachable without the application agreeing that it is its name.
|
|
hostnames: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MTA-STS — the policy route, in BOTH routing modes (Gitea #123)
|
|
# ---------------------------------------------------------------------------
|
|
# MTA-STS lets a sending mail server find out, over HTTPS, that this domain
|
|
# expects TLS. Stalwart serves the policy file itself — this block is only
|
|
# about ROUTING a sending MTA to it, and the route has to exist in whichever
|
|
# mode this chart is publishing in.
|
|
#
|
|
# WHAT IS RENDERED, AND IT IS NOT THE SAME SHAPE ON BOTH SIDES:
|
|
#
|
|
# Ingress mode — ONE Ingress object PER DISTINCT clusterIssuer, each with one
|
|
# rule per host and its tls: list grouped by secret name.
|
|
# Several objects because cert-manager's cluster-issuer is an
|
|
# object-level ANNOTATION while secretName is a per-tls-entry
|
|
# field, so one object cannot both request a certificate from
|
|
# issuer X and leave another host's secret alone. The full
|
|
# argument, with the cert-manager behaviour it was measured
|
|
# against, is in stalwart.mtaSts.ingressGroups in
|
|
# _helpers.tpl.
|
|
# Named BY INDEX over those groups —
|
|
# <release>-stalwart-mta-sts-ingress, then -1, -2 — in this
|
|
# repo's idiom (README/style guide section 4, as
|
|
# homeassistant's routes and keycloak's realm Ingresses do
|
|
# it). So the NAME DOES NOT SAY WHICH ISSUER it carries:
|
|
# NOTES.txt prints that mapping, and it is the only place the
|
|
# association is visible.
|
|
# REORDERING THIS LIST IS NOT FREE. The index runs over the
|
|
# issuer groups, which are derived from the list, so moving
|
|
# one entry can change how many objects exist and which index
|
|
# holds which issuer — renumbering objects that were not
|
|
# themselves moved. Kubernetes recreates them, which takes
|
|
# their Certificate objects along; nothing is RE-ISSUED,
|
|
# because the Secrets survive (cert-manager only
|
|
# owner-references a Secret with
|
|
# --enable-certificate-owner-ref, which this cluster does not
|
|
# set). That last part is a property of the CLUSTER and not
|
|
# of this chart: with that flag on, a reorder costs a fresh
|
|
# ACME request per renumbered object.
|
|
# Gateway mode — ONE HTTPRoute with all the hostnames. Nothing differs per
|
|
# host here (same path, same backend, no filter) and
|
|
# spec.hostnames is a list, so one object says exactly what
|
|
# is meant. TLS sits on the Gateway's listener and this chart
|
|
# renders no certificate for it.
|
|
#
|
|
# Both point at the same per-group http Service the WebUI route points at
|
|
# (ingress.nodeGroup), on the one path RFC 8461 allows.
|
|
#
|
|
# THE LIST IS THE SWITCH. There is deliberately no mtaSts.enabled: "enabled
|
|
# with an empty list" would be a state with no meaning that the chart would
|
|
# then have to catch. Empty list — the default — renders nothing in either
|
|
# mode, so this block changes nothing about a running installation until it is
|
|
# filled.
|
|
#
|
|
# THE PATH IS HARDWIRED and is not a value. RFC 8461 fixes it: the policy is
|
|
# fetched from /.well-known/mta-sts.txt on mta-sts.<domain>, with pathType
|
|
# Exact (type: Exact in the HTTPRoute) because exactly one file lives there. A
|
|
# values key for a protocol-mandated path is a key that can only ever be set
|
|
# wrong — and "wrong" here is invisible from this side, see the limit below.
|
|
# Exact rather than a prefix also matters in its own right: a prefix on these
|
|
# hostnames would publish the mail server's whole management surface under
|
|
# them.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# THE LIMIT, AND IT HAS TO BE SAID RATHER THAN SUGGESTED AWAY
|
|
# ------------------------------------------------------------------------
|
|
# THIS CHART REGISTERS A ROUTE. IT DOES NOT CREATE A DOMAIN. Read the first
|
|
# paragraph of this file again: since v0.16 the domains live in Stalwart's DATA
|
|
# STORE, and this chart never sees them. So whether the server answers for
|
|
# mta-sts.example.com depends on example.com existing in that data store, which
|
|
# no value here can arrange.
|
|
#
|
|
# An entry without its domain in the data store gives a 404 AT A CORRECTLY
|
|
# ROUTED ENDPOINT — every Kubernetes object green, the Ingress or route
|
|
# resolving perfectly, and nothing wrong on this side at all. And the other
|
|
# half of it is worse: MTA-STS failures are silent ON THE SENDER'S SIDE. A
|
|
# sending MTA that finds no policy does not report anything here; it simply
|
|
# falls back to delivering in the clear. So the thing this block exists to
|
|
# achieve can be absent with no symptom in this cluster whatsoever.
|
|
#
|
|
# That is the same genus as the OnePasswordItem limit this chart already
|
|
# documents: a promise the chart cannot keep, and therefore one it has to name.
|
|
# Check an entry from outside after adding it:
|
|
# curl https://mta-sts.<domain>/.well-known/mta-sts.txt
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# THE SUBDOMAIN TRAP, which is the one that will actually catch somebody
|
|
# ------------------------------------------------------------------------
|
|
# mta-sts.sub.example.com is ONE LABEL DEEPER than *.example.com, and a
|
|
# wildcard certificate matches exactly one label (RFC 6125). So a wildcard for
|
|
# the parent domain does NOT cover it, and the obvious assumption — "the
|
|
# wildcard already covers that" — is false.
|
|
#
|
|
# With a secret per entry that is solved, which is why the field exists. The
|
|
# trap is written down anyway because the symptom is a TLS error AT THE SENDING
|
|
# MTA: nothing in this cluster logs it, the endpoint looks healthy from here,
|
|
# and mail keeps flowing unencrypted. In Gateway mode the same trap moves to
|
|
# the Gateway's listener, where this chart cannot help at all — a wildcard
|
|
# listener for the parent domain will not terminate TLS for a deeper name.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# WHY A SECRET PER ENTRY AND NOT certificate.extraDnsNames
|
|
# ------------------------------------------------------------------------
|
|
# The alternative was real and was rejected. This chart already has
|
|
# "Additional DNS names for the mail certificate" (certificate.extraDnsNames,
|
|
# further up this file), so the mta-sts.* names could have been folded into the
|
|
# mail certificate instead of carrying their own secrets.
|
|
#
|
|
# They are not, because that would hang the MAIL certificate on this list. The
|
|
# mail certificate is what serves STARTTLS on 25 and implicit TLS on 465/993;
|
|
# a bad entry here — a typo, a name whose DNS-01 challenge cannot be answered —
|
|
# would then fail the issuance of the certificate the mail ports depend on.
|
|
# A policy route that is only a nice-to-have would be able to take SMTP and
|
|
# IMAP with it. Separate secrets keep the blast radius at the entry.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# NOT DONE BY THIS CHART, so it is not mistaken for done
|
|
# ------------------------------------------------------------------------
|
|
# - THE DNS RECORD. MTA-STS also needs a TXT record at _mta-sts.<domain>;
|
|
# without it no sending MTA ever looks for the policy. That is DNS, not
|
|
# Kubernetes, and nothing here creates it.
|
|
# - THE POLICY'S CONTENT. version/mode/max_age/mx come from Stalwart's data
|
|
# store, per domain. The running installation serves mode: testing, which
|
|
# means sending MTAs enforce nothing — also a data-store setting and not a
|
|
# chart question.
|
|
# - REMOVING AN EXISTING OBJECT FROM ANOTHER REPOSITORY. Where a route for
|
|
# one of these hosts already exists elsewhere (jk-cluster-config does serve
|
|
# mta-sts.bekemeyer-klein.de from a Fleet release of its own), the order is
|
|
# REMOVE THERE FIRST, then add the entry here. The other way round,
|
|
# ingress-nginx's admission webhook rejects the second object on the same
|
|
# host and path — which is the loud failure, but it is a failure.
|
|
mtaSts:
|
|
# Empty by default: no object in either mode, and no value becomes required.
|
|
#
|
|
# Entry form, and the two TLS fields apply ONLY IN INGRESS MODE:
|
|
#
|
|
# host REQUIRED. The policy hostname, i.e. mta-sts.<domain>.
|
|
# tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose:
|
|
# the plausible default (this release's own
|
|
# tls-<release>-<chart>-ingress) is the wrong certificate —
|
|
# it covers ingress.domain, not this host. Hosts that share
|
|
# one secret are collected into one tls: entry, so one
|
|
# certificate with several SANs is asked for once rather
|
|
# than once per host. Inert in Gateway mode.
|
|
# clusterIssuer OPTIONAL, and EMPTY MEANS NO ANNOTATION — i.e. "the
|
|
# certificate for this host comes from somewhere else, do
|
|
# not ask cert-manager for it". That is what keeps a secret
|
|
# owned by another release from being fought over, the same
|
|
# deliberate omission as the qBittorrent object in
|
|
# freesailarr and two of the four realm ingresses in Gitea
|
|
# #122. Entries sharing a secret must agree on it (the
|
|
# render aborts naming both), because cert-manager names the
|
|
# Certificate after the secret and the issuer is an
|
|
# object-level annotation. Inert in Gateway mode.
|
|
#
|
|
# The chart's own ingress.annotations are NOT copied onto these objects: they
|
|
# belong to the WebUI/JMAP host (on this installation they are its CORS
|
|
# configuration), and a policy file served to sending MTAs has no use for
|
|
# them.
|
|
#
|
|
# hosts:
|
|
# - host: mta-sts.example.com
|
|
# tlsSecretName: tls-mta-sts-example-com-ingress
|
|
# clusterIssuer: letsencrypt-issuer
|
|
# # A deeper name needs its own certificate — the parent's wildcard does
|
|
# # not reach it. See the subdomain trap above.
|
|
# - host: mta-sts.sub.example.com
|
|
# tlsSecretName: tls-mta-sts-sub-example-com-ingress
|
|
# clusterIssuer: letsencrypt-issuer
|
|
# # Certificate managed elsewhere: no issuer, so no annotation and no
|
|
# # Certificate requested by this release.
|
|
# - host: mta-sts.other.example
|
|
# tlsSecretName: tls-some-existing-wildcard-ingress
|
|
hosts: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardening — chart-wide, applied to every node group
|
|
# ---------------------------------------------------------------------------
|
|
securityContext:
|
|
pod:
|
|
# uid/gid 2000 is the image's own "stalwart" user: the image already runs
|
|
# as non-root, so nothing has to be bent here.
|
|
runAsNonRoot: true
|
|
runAsUser: 2000
|
|
runAsGroup: 2000
|
|
# In the pod context so it also covers the init container that places
|
|
# config.json; a container-level profile would have to be repeated for
|
|
# every container and would be missed on the next one added.
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
# No fsGroup and no fsGroupChangePolicy: this chart mounts no PVC. Both
|
|
# volumes are emptyDirs (state lives in Postgres, S3 and Valkey) and the
|
|
# kubelet creates those owned by the pod's user already.
|
|
container:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
runAsNonRoot: true
|
|
# DOCUMENTED DEVIATION from the repo's hardening baseline (README,
|
|
# guideline 5), forced by the stalwartlabs/stalwart image. Precedent for a
|
|
# justified capability: samba.
|
|
#
|
|
# NET_BIND_SERVICE IS REQUIRED TO *START* THE CONTAINER, NOT TO BIND A PORT.
|
|
# Both published image variants run
|
|
# setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
|
|
# (Dockerfile.build at v0.16.24, line 164 for the glibc target and line 186
|
|
# for the musl/-alpine one). The "e" in "+ep" sets the file's effective
|
|
# bit, which makes the binary a "capability-dumb binary" in the kernel's
|
|
# terms, and capabilities(7) then applies this check at execve time:
|
|
#
|
|
# "When executing a capability-dumb binary, the kernel checks if the
|
|
# process obtained all permitted capabilities that were specified in the
|
|
# file permitted set... If the process did not obtain the full set of
|
|
# file permitted capabilities, then execve(2) fails with the error
|
|
# EPERM."
|
|
#
|
|
# drop: [ALL] empties the bounding set, so the capability cannot be
|
|
# obtained, so the exec fails outright:
|
|
# exec /usr/local/bin/stalwart: operation not permitted
|
|
# The check runs BEFORE the program binds anything, and it does not ask
|
|
# whether the capability is needed. This is why the three escape routes
|
|
# that look obvious all fail for the same reason: high ports do not help,
|
|
# the -alpine image carries the identical setcap, and the
|
|
# net.ipv4.ip_unprivileged_port_start sysctl addresses binding rather than
|
|
# exec.
|
|
#
|
|
# AND THE SECOND HALF, WHICH IS WHY THIS LINE MUST NOT BE "CLEANED UP":
|
|
# THE CAPABILITY IS NEVER ACTUALLY USED. It is spent entirely on satisfying
|
|
# the execve check. Stalwart does bind 25/465/993 here, which are
|
|
# privileged — but inside a pod that needs no capability either way: the
|
|
# kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network
|
|
# namespace, so a non-root container binds low ports regardless (measured
|
|
# in k3d for template-chart, see its ports comment). So a hardening pass
|
|
# that greps for "a capability nobody uses" will find exactly this one and
|
|
# removing it stops the chart from starting at all. Do not remove it
|
|
# without first checking whether upstream still sets the file capability;
|
|
# the day that setcap disappears from Dockerfile.build is the day this
|
|
# deviation can go with it.
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
add:
|
|
- NET_BIND_SERVICE
|
|
|
|
# Probes for every node group, on the two separate endpoints the image
|
|
# provides. Values-configurable; an erased block falls back to these defaults
|
|
# rather than removing the probe.
|
|
#
|
|
# THE INTERVALS ARE CALCULATED, NOT TASTE. DO NOT TIGHTEN THEM WITHOUT REDOING
|
|
# THE ARITHMETIC BELOW.
|
|
#
|
|
# Stalwart applies an anonymous HTTP rate limit to /healthz as to everything
|
|
# else, and its default is 100 requests per 60 seconds KEYED ON THE SOURCE IP
|
|
# (registry default rate_limit_anonymous: count 100, period 60000ms). Kubelet
|
|
# probes arrive from the IP of the NODE running the pod, not from the pod, so
|
|
# every Stalwart pod on one node draws from the SAME bucket. Exceeding it
|
|
# returns 429, a 429 fails the probe, and failing liveness restarts the pod —
|
|
# a self-inflicted restart loop that gets worse the more pods a node holds.
|
|
#
|
|
# The budget per pod, with the values below:
|
|
# steady state liveness 60/20 = 3/min + readiness 60/20 = 3/min = 6/min
|
|
# while starting startup 60/10 = 6/min = 6/min
|
|
# The startup probe GATES liveness and readiness, so the three never run at
|
|
# once and 6 requests/min/pod is the worst case in both phases. Against a
|
|
# 100/60s bucket that leaves room for about 16 Stalwart pods per node before
|
|
# the limit is in sight.
|
|
#
|
|
# For comparison, the obvious 10/10/5 defaults would cost 12/min/pod in steady
|
|
# state and allow only about 8 pods per node — which is not far from a
|
|
# plausible deployment. That is the whole reason these numbers are 20/20/10.
|
|
#
|
|
# What the wider intervals cost, stated so the trade is visible: up to 20s
|
|
# before unreadiness removes a pod from its Services, and up to 60s
|
|
# (3 x 20s) before a hung pod is restarted. For a mail server both are
|
|
# acceptable — SMTP senders retry for days.
|
|
#
|
|
# WHY HTTP AND NOT A tcpSocket READINESS PROBE. A tcpSocket probe on the mail
|
|
# port would bypass the bucket entirely, and it was considered for exactly that
|
|
# reason. It is rejected on three grounds, none of them about convenience:
|
|
# 1. It would be GREEN in precisely the failure this chart is built to
|
|
# contain. A node that fail-opens (its ClusterRole missing from the
|
|
# database) binds every listener, so a TCP connect to 25 succeeds on a
|
|
# broken "mx" node just as it does on a healthy "full" one.
|
|
# 2. It proves strictly less. A TCP accept says a socket is bound;
|
|
# /healthz/ready additionally says the server reached the point where a
|
|
# data store handle exists at all.
|
|
# 3. It would have to differ per node group (the "mx" group has no 465 or
|
|
# 993), so the fallback default in _helpers.tpl could no longer carry the
|
|
# probe's context — the failure mode guideline 12.5 exists for.
|
|
# If a node ever does need to carry more than ~16 pods, the right move is to
|
|
# raise the limit on the server (the allow-list bypasses the limiter outright
|
|
# for listed addresses) rather than to blind the probes.
|
|
#
|
|
# WHAT THESE PROVE, AND WHAT THEY DO NOT — three honest limits:
|
|
# - Liveness is deliberately on /healthz/live so a Postgres outage cannot
|
|
# restart every pod in the cluster at once; the restart would fix nothing
|
|
# and would drop every connection in flight (guideline 12.3).
|
|
# - /healthz/ready DOES NOT QUERY THE DATABASE. Read from the source at
|
|
# v0.16.24: it returns 200 iff the data store handle is not Store::None.
|
|
# So readiness here does not hang on the dependency the way guideline 12.3
|
|
# asks, and it cannot — the endpoint upstream offers is shallow. Named
|
|
# rather than papered over, because a green pod proves less than it looks.
|
|
# - It also does not catch the missing-config.json trap (a server that fell
|
|
# back to an ephemeral in-memory store still has a data store handle).
|
|
#
|
|
# NOT VERIFIED AGAINST A RUNNING SERVER: whether /healthz answers a plain
|
|
# kubelet probe at all. The source shows no auth, no loopback check and no
|
|
# trusted-network restriction, but the request still passes the configurable
|
|
# Http.allowedEndpoints expression (default "200", i.e. everything allowed) —
|
|
# and its loopback escape hatch does NOT cover a node IP, so an operator who
|
|
# later writes a restrictive expression breaks every probe. The image's own
|
|
# healthcheck sends "X-Forwarded-For: 127.0.0.1", which is how it puts itself
|
|
# in the loopback rate-limit bucket; that only has an effect when
|
|
# Http.useXForwarded is on, and it is off by default.
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /healthz/live
|
|
port: http
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 3
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /healthz/ready
|
|
port: http
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 3
|
|
# The startup probe gates liveness and readiness, so neither needs an
|
|
# initialDelaySeconds. Budget = periodSeconds x failureThreshold = 300s, which
|
|
# is generous on purpose: the first start of a node has to reach Postgres,
|
|
# acquire its cluster node-id lease and read the whole configuration registry
|
|
# out of the database.
|
|
startupProbe:
|
|
httpGet:
|
|
path: /healthz/ready
|
|
port: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 30
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The Valkey subchart — THIS REPO'S OWN valkey CHART, off by default
|
|
# ---------------------------------------------------------------------------
|
|
# Stalwart needs an in-memory store for a cluster, and the coordinator rides on
|
|
# the same connection. The subchart is the path for a deployment that has no
|
|
# Valkey yet; an installation that already runs one points
|
|
# externalServices.valkey.host at it and leaves this off.
|
|
#
|
|
# THE SWITCH IS HERE AND NOT UNDER "valkey:", and it has to be. Everything under
|
|
# "valkey:" is handed to the subchart as its values, and our valkey chart ships
|
|
# a strict schema (additionalProperties: false) with no "enabled" property — so
|
|
# "valkey.enabled" is rejected BY THE SUBCHART. Measured; and because Helm skips
|
|
# a disabled subchart's schema validation, the error would only ever appear for
|
|
# whoever first switched it on. Chart.yaml has the error text.
|
|
valkeySubchart:
|
|
enabled: false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Values passed THROUGH to the valkey subchart
|
|
# ---------------------------------------------------------------------------
|
|
# Read as the subchart's own values, so the key names are ITS schema, not
|
|
# upstream's and not this chart's. `valkey/values.yaml` in the repo is the
|
|
# reference; its header comment explains the pairs. Nothing here has any effect
|
|
# while valkeySubchart.enabled is false.
|
|
#
|
|
# WHY THIS IS NOT "upstream's chart as it comes": it is our chart, so there is
|
|
# no third party's defaults to leave unpatched and no shortfall against this
|
|
# repo's conventions to document — it follows them because it IS one of them.
|
|
# What still applies in full is the subchart being invisible downstream (a
|
|
# Valkey fix reaches a cluster only when THIS chart is republished — Chart.yaml
|
|
# has the detail and the PR-ordering warning), that scaleDown and
|
|
# checksum/config stop at the subchart boundary, and that every helm command
|
|
# needs ci/helm-dependencies.sh to have run first.
|
|
valkey:
|
|
secrets:
|
|
# REQUIRED once the subchart is on — the subchart's own required() aborts
|
|
# the render without it, which is the correct loud failure.
|
|
#
|
|
# MIND THE FIELD, it is not a password. The 1Password item carries ONE
|
|
# field, `auth-conf`, whose value is the COMPLETE configuration line
|
|
# requirepass <password>
|
|
# The valkey chart mounts that file and `include`s it, and an init container
|
|
# refuses to start the pod unless the line is really there — because the
|
|
# alternative failure is an open server that looks perfectly healthy. A bare
|
|
# password in that field does not work.
|
|
authSecretRef: null
|
|
|
|
# CACHE, not queue — a decision, with its cost named.
|
|
#
|
|
# The valkey chart treats persistence and eviction as a PAIR and warns against
|
|
# mixing them: QUEUE is `persistence.enabled: true` + `noeviction`, CACHE is
|
|
# `persistence.enabled: false` + `allkeys-lru`.
|
|
#
|
|
# Stalwart's in-memory store holds rate-limiter counters, auth tokens and
|
|
# sessions, auto-ban entries, and the cluster coordination pub/sub. Every one
|
|
# of those is reconstructible: counters and bans restart from zero, tokens and
|
|
# sessions force a re-authentication, and pub/sub messages are not stored keys
|
|
# at all. Nothing in it is the system of record — messages and metadata are in
|
|
# Postgres, blobs in S3, and the cluster node-id lease is in the data store
|
|
# too, not here. Paying for a PVC and for AOF replay to protect data that is
|
|
# worthless after a restart is the wrong trade, so: cache.
|
|
#
|
|
# THE COST, because `allkeys-lru` means keys really are discarded under memory
|
|
# pressure: the rate limiters and the auto-ban FAIL OPEN. An attacker who can
|
|
# drive memory usage can age out their own ban entry. The answer is to size
|
|
# memory for the working set rather than to switch to `noeviction` — with
|
|
# `noeviction` Stalwart's writes to the store start erroring instead, which
|
|
# takes out authentication rather than just the limits. Size it, and treat the
|
|
# limiters as a nuisance filter, not as a security boundary.
|
|
persistence:
|
|
enabled: false
|
|
|
|
valkey:
|
|
maxmemoryPolicy: allkeys-lru
|
|
# The subchart derives maxmemory from the computed memory limit and aborts
|
|
# its render if an explicit maxmemory exceeds it. Defaults left alone.
|
|
resources:
|
|
requests:
|
|
cpu: 50m
|
|
memory: 128Mi
|
|
ephemeral-storage: 32Mi
|
|
|
|
# The subchart's own NetworkPolicy, with its own semantics — this chart's
|
|
# networkPolicy block does not reach it. On by default there, allowing the
|
|
# release's own namespace. It needs an entry here only when Stalwart runs in a
|
|
# DIFFERENT namespace from the Valkey, which with the subchart it does not.
|
|
networkPolicy:
|
|
allowedNamespaces: []
|