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
724 lines
36 KiB
Smarty
724 lines
36 KiB
Smarty
{{/*
|
|
Resource / replica / null-safety helpers.
|
|
|
|
These are copied from template-chart with "stalwart." as the define prefix,
|
|
as in every other chart here: sharing them would mean a library chart, and a
|
|
dependency is admissible but expensive (README, guideline 14) - this chart
|
|
already carries one and does not need a second. template-chart holds the
|
|
reference implementation; the resource-quantity render test in CI exists
|
|
because a copy can carry an old bug into a diff nobody reads.
|
|
*/}}
|
|
{{- define "stalwart.resources" -}}
|
|
{{- $resources := . | default dict -}}
|
|
{{- $factor := 3.0 -}}
|
|
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
|
{{- $factor = float64 $resources.limitFactor -}}
|
|
{{- end -}}
|
|
{{- $requests := dict -}}
|
|
{{- range $key, $value := ($resources.requests | default dict) -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $_ := set $requests (include "stalwart.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $limits := dict -}}
|
|
{{- range $key, $value := ($resources.limits | default dict) -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $_ := set $limits (include "stalwart.resourceKey" $key) $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- range $key, $request := $requests -}}
|
|
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
|
{{- $_ := set $limits $key (include "stalwart.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $out := dict -}}
|
|
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
|
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
|
{{- toYaml $out -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Normalize a resource name from values to its Kubernetes key.
|
|
*/}}
|
|
{{- define "stalwart.resourceKey" -}}
|
|
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
|
Input: dict "quantity" <string|number> "factor" <float64>.
|
|
*/}}
|
|
{{- define "stalwart.multiplyQuantity" -}}
|
|
{{- $quantity := include "stalwart.formatNumber" .quantity -}}
|
|
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
|
{{- if not $number -}}
|
|
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
|
{{- end -}}
|
|
{{- $suffix := trimPrefix $number $quantity -}}
|
|
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
|
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
|
{{- end -}}
|
|
{{- $product := mulf (float64 $number) .factor -}}
|
|
{{- if eq $product (floor $product) -}}
|
|
{{- printf "%d%s" (int64 $product) $suffix -}}
|
|
{{- else -}}
|
|
{{- printf "%s%s" (include "stalwart.formatNumber" $product) $suffix -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Render a number as a plain decimal string, never in exponential notation
|
|
(issue #157): the null-safe helpers pass values through fromYaml, which parses
|
|
every number as float64, and Go's "%v" switches to exponential notation from
|
|
about 1e6 upwards - not a valid Kubernetes quantity.
|
|
*/}}
|
|
{{- define "stalwart.formatNumber" -}}
|
|
{{- if kindIs "float64" . -}}
|
|
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
|
{{- else -}}
|
|
{{- printf "%v" . -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The replica count of one node group.
|
|
Input: dict "scaleDown" <bool|nil> "replicas" <int|nil>.
|
|
The explicit kindIs "invalid" check is what keeps an explicit 0 - which this
|
|
chart relies on, since the "mx" group ships with replicas: 0.
|
|
*/}}
|
|
{{- define "stalwart.replicas" -}}
|
|
{{- if .scaleDown -}}
|
|
0
|
|
{{- else if not (kindIs "invalid" .replicas) -}}
|
|
{{- .replicas -}}
|
|
{{- else -}}
|
|
1
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
|
null / empty override falls back to the defaults instead of erasing them
|
|
(issues #61 and #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
|
<string>. Output: YAML, consume it with fromYaml.
|
|
*/}}
|
|
{{- define "stalwart.defaultedDict" -}}
|
|
{{- $defaults := .defaults | default dict -}}
|
|
{{- $given := .given -}}
|
|
{{- $path := .path | default "value" -}}
|
|
{{- if kindIs "invalid" $given -}}
|
|
{{- $given = dict -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $given) -}}
|
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
|
{{- end -}}
|
|
{{- $out := deepCopy $defaults -}}
|
|
{{- range $key, $value := $given -}}
|
|
{{- if not (kindIs "invalid" $value) -}}
|
|
{{- $default := index $defaults $key -}}
|
|
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
|
{{- $_ := set $out $key (fromYaml (include "stalwart.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
|
{{- else if kindIs "map" $default -}}
|
|
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
|
{{- else -}}
|
|
{{- $_ := set $out $key $value -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- toYaml $out -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
Pick one sub-block out of a values block that may itself be null.
|
|
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
|
*/}}
|
|
{{- define "stalwart.subBlock" -}}
|
|
{{- $block := .block -}}
|
|
{{- if kindIs "invalid" $block -}}
|
|
{{- $block = dict -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $block) -}}
|
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
|
{{- end -}}
|
|
{{- toYaml (dict "value" (index $block .key)) -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The effective security contexts, probes and resources (issue #99).
|
|
|
|
The defaults below MUST stay in sync with values.yaml, which stays the
|
|
documented, user-facing place for them; this copy is only the fallback for the
|
|
case where an override has erased the block.
|
|
|
|
Two rules these fallbacks exist to honour (README, guideline 12):
|
|
- reproduce THIS chart's defaults, not the repo standard. uid/gid 2000 is the
|
|
image's own user, and there is deliberately NO fsGroup here because this
|
|
chart mounts no PVC.
|
|
- carry the probe's CONTEXT, not just its timings. The paths and the port
|
|
name are part of these defaults; without them the probes would hit "/" on
|
|
a port that may not exist, and liveness would stop being independent of
|
|
the data store.
|
|
*/}}
|
|
{{- define "stalwart.podSecurityContext" -}}
|
|
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "pod" "path" "securityContext"))).value -}}
|
|
{{- $defaults := dict
|
|
"runAsNonRoot" true
|
|
"runAsUser" 2000
|
|
"runAsGroup" 2000
|
|
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "stalwart.containerSecurityContext" -}}
|
|
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "container" "path" "securityContext"))).value -}}
|
|
{{/*
|
|
NET_BIND_SERVICE is part of the FALLBACK, not only of values.yaml, and that is
|
|
the whole point of rule "reproduce THIS chart's defaults, not the repo
|
|
standard". The image carries setcap cap_net_bind_service=+ep on its binary, so
|
|
with an empty bounding set the kernel fails the execve itself - an erased
|
|
securityContext block that fell back to the repo-standard "add: []" would stop
|
|
the chart from starting at all, with "operation not permitted" and nothing
|
|
pointing at this file.
|
|
*/}}
|
|
{{- $defaults := dict
|
|
"allowPrivilegeEscalation" false
|
|
"readOnlyRootFilesystem" true
|
|
"runAsNonRoot" true
|
|
"capabilities" (dict "drop" (list "ALL") "add" (list "NET_BIND_SERVICE")) -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.container") -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The probe fallbacks carry the calculated intervals, not just the paths. The
|
|
arithmetic is in values.yaml: Stalwart rate-limits anonymous /healthz requests
|
|
to 100 per 60s PER SOURCE IP, probes come from the node IP, so every pod on a
|
|
node shares one bucket. 20/20/10 costs 6 requests/min/pod in both phases
|
|
(the startup probe gates the other two, so they never run together) and leaves
|
|
room for ~16 pods per node. The obvious 10/10/5 would halve that to ~8.
|
|
Tightening these here without redoing that sum produces 429s, and a 429 on
|
|
liveness is a restart loop.
|
|
*/}}
|
|
{{- define "stalwart.livenessProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthz/live" "port" "http")
|
|
"periodSeconds" 20
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 3 -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "livenessProbe") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "stalwart.readinessProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthz/ready" "port" "http")
|
|
"periodSeconds" 20
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 3 -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "readinessProbe") -}}
|
|
{{- end -}}
|
|
|
|
{{- define "stalwart.startupProbe" -}}
|
|
{{- $defaults := dict
|
|
"httpGet" (dict "path" "/healthz/ready" "port" "http")
|
|
"periodSeconds" 10
|
|
"timeoutSeconds" 5
|
|
"failureThreshold" 30 -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
=============================================================================
|
|
Chart-specific helpers
|
|
=============================================================================
|
|
*/}}
|
|
|
|
{{/*
|
|
The common name prefix. One place, because it appears in every object name and
|
|
in the Service selectors.
|
|
*/}}
|
|
{{- define "stalwart.fullname" -}}
|
|
{{- printf "%s-%s" .Release.Name .Chart.Name -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The validated "ports" map. Rejects a non-mapping and an empty map, and checks
|
|
every entry for the one field the chart cannot do without.
|
|
|
|
Why the number is checked here rather than where it is used: an erased
|
|
ports.smtp.number used to render "port:" and "targetPort:" empty into the
|
|
Service and an empty "containerPort" into the StatefulSet - rejected by nothing
|
|
until the API server saw it (the satisfactory lesson, Gitea #16).
|
|
*/}}
|
|
{{- define "stalwart.ports" -}}
|
|
{{- $ports := .Values.ports -}}
|
|
{{- if kindIs "invalid" $ports -}}
|
|
{{- fail "ports must be a mapping of port name to { containerPort, nodePort, protocol, public }, and it cannot be erased: every Service, containerPort and NetworkPolicy rule in this chart is rendered from it" -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $ports) -}}
|
|
{{- fail (printf "ports must be a mapping, got %s" (kindOf $ports)) -}}
|
|
{{- end -}}
|
|
{{- if eq (len $ports) 0 -}}
|
|
{{- fail "ports is empty - the chart would render workloads with no containerPort and no Service at all" -}}
|
|
{{- end -}}
|
|
{{- range $name, $spec := $ports -}}
|
|
{{- if not (kindIs "map" $spec) -}}
|
|
{{- fail (printf "ports.%s must be a mapping, got %s (%v)" $name (kindOf $spec) $spec) -}}
|
|
{{- end -}}
|
|
{{- if kindIs "invalid" $spec.containerPort -}}
|
|
{{- fail (printf "ports.%s.containerPort is required - a port without one renders an empty containerPort and an empty Service targetPort. Note containerPort and nodePort are two different numbers here and are not interchangeable: containerPort is what the application binds (and what the NetworkListener object in the database must say), nodePort is what the cluster publishes" $name) -}}
|
|
{{- end -}}
|
|
{{- if and $spec.public (kindIs "invalid" $spec.nodePort) -}}
|
|
{{- fail (printf "ports.%s is marked public but has no nodePort - a NodePort Service without a nodePort would be assigned a random one, which the HA proxy in front of the cluster does not know about" $name) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- toYaml $ports -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The validated "nodes" map, with each group's exposedPorts checked against the
|
|
ports map. Input: the root context.
|
|
|
|
The exposedPorts check is the one that earns its keep: a typo there would
|
|
silently drop a port from a group's containerPorts and from its NetworkPolicy,
|
|
leaving a Service selecting pods that never bound the port.
|
|
*/}}
|
|
{{- define "stalwart.nodes" -}}
|
|
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
|
{{- $nodes := .Values.nodes -}}
|
|
{{- if kindIs "invalid" $nodes -}}
|
|
{{- fail "nodes must be a mapping of cluster-role name to node group, and it cannot be erased: it is what every StatefulSet in this chart is rendered from" -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "map" $nodes) -}}
|
|
{{- fail (printf "nodes must be a mapping of cluster-role name to node group, got %s - note it is a MAP and not a list, so that an override never depends on a position" (kindOf $nodes)) -}}
|
|
{{- end -}}
|
|
{{- if eq (len $nodes) 0 -}}
|
|
{{- fail "nodes is empty - the release would contain Services and an Ingress but no workload" -}}
|
|
{{- end -}}
|
|
{{- range $group, $spec := $nodes -}}
|
|
{{- if not (kindIs "map" $spec) -}}
|
|
{{- fail (printf "nodes.%s must be a mapping, got %s (%v)" $group (kindOf $spec) $spec) -}}
|
|
{{- end -}}
|
|
{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $group) -}}
|
|
{{- fail (printf "nodes.%s is not usable as a Kubernetes name segment: a group key becomes part of every object name and of a label value, so it must be lowercase alphanumeric with dashes. It is ALSO the cluster-role name passed in STALWART_ROLE, so renaming it here without renaming the ClusterRole object in the database turns the group into a full node" $group) -}}
|
|
{{- end -}}
|
|
{{- $exposed := $spec.exposedPorts -}}
|
|
{{- if kindIs "invalid" $exposed -}}
|
|
{{- fail (printf "nodes.%s.exposedPorts is required - a group with no ports is unreachable, and because this list is also the NetworkPolicy's ingress rule, an erased one is indistinguishable from a deliberate lockout" $group) -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "slice" $exposed) -}}
|
|
{{- fail (printf "nodes.%s.exposedPorts must be a list of port names, got %s (%v)" $group (kindOf $exposed) $exposed) -}}
|
|
{{- end -}}
|
|
{{- range $exposed -}}
|
|
{{- if not (hasKey $ports .) -}}
|
|
{{- fail (printf "nodes.%s.exposedPorts names %q, which is not a key of ports: - known ports are %s. Left unchecked this would drop the port from the group's containerPorts and from its NetworkPolicy while the Service still selected its pods" $group . (keys $ports | sortAlpha | join ", ")) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- if not (has "http" $exposed) -}}
|
|
{{- fail (printf "nodes.%s.exposedPorts does not include \"http\", but all three probes target that port - the group would never become Ready. Every group needs it, including an SMTP-only one; it is simply not published there" $group) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- toYaml $nodes -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The effective resources of one node group.
|
|
Input: dict "group" <name> "given" <the group's resources block|nil>
|
|
"limitFactor" <the chart-global .Values.limitFactor|nil>.
|
|
|
|
The per-group defaults are reproduced here, not a single generic set: "mx" is
|
|
deliberately lighter than "full", and a fallback that handed an erased
|
|
mx.resources the heavier figures would quietly change what the chart had been
|
|
rendering. A group the chart does not ship gets the "full" figures, which is
|
|
the safe direction - too much accounting rather than too little.
|
|
*/}}
|
|
{{- define "stalwart.effectiveResources" -}}
|
|
{{- $factor := 3 -}}
|
|
{{- if not (kindIs "invalid" .limitFactor) -}}
|
|
{{- $factor = .limitFactor -}}
|
|
{{- end -}}
|
|
{{- $requests := dict "cpu" "200m" "memory" "512Mi" "ephemeral-storage" "64Mi" -}}
|
|
{{- if eq .group "mx" -}}
|
|
{{- $requests = dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi" -}}
|
|
{{- end -}}
|
|
{{- $defaults := dict "limitFactor" $factor "requests" $requests -}}
|
|
{{- $merged := fromYaml (include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s.resources" .group))) -}}
|
|
{{- include "stalwart.resources" $merged -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The label a pod carries for one port it is reachable on. The per-port Services
|
|
select on these, which is what lets one NodePort Service serve several node
|
|
groups - NodePorts are cluster-globally unique, so "full" and "mx" can only
|
|
accept SMTP behind the SAME Service object.
|
|
*/}}
|
|
{{- define "stalwart.portLabel" -}}
|
|
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The labels that identify ONE node group's pods - and the ONLY definition of
|
|
them. Input: dict "root" <the root context> "group" <name>. Output: YAML.
|
|
|
|
Three places consume this: the pod template's labels, the labelSelector of the
|
|
chart's own topology spread constraint, and the selector of the group's
|
|
PodDisruptionBudget. All three have to agree, and the failure of a disagreement
|
|
is silent in both of the latter two:
|
|
|
|
- a spread constraint whose labelSelector matches no pod is TRIVIALLY
|
|
SATISFIED. It distributes nothing, the rollout is green, and nothing is
|
|
logged. With whenUnsatisfiable: DoNotSchedule that is worse rather than
|
|
better, because the object then looks like a guarantee.
|
|
- a PDB whose selector matches no pod reports ALLOWED DISRUPTIONS equal to
|
|
its own minAvailable arithmetic over zero pods and never blocks an
|
|
eviction, so a drain takes every replica at once - the thing the object was
|
|
added to prevent.
|
|
|
|
One definition is the precedent set by stalwart.env.dbPassword in this file,
|
|
for exactly the same reason: a mismatch between two copies is not a render
|
|
error. ci/run-selector-match-test.sh is the second half of that - it holds
|
|
every rendered selector in this repository against the labels of the pod
|
|
templates in the same render, because one definition only helps while nobody
|
|
writes a second one.
|
|
*/}}
|
|
{{- define "stalwart.groupLabels" -}}
|
|
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" .root }}
|
|
stalwart.jk-dev.de/node-group: {{ .group }}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The effective scheduling and disruption settings of one node group (#99 shape).
|
|
Input: dict "group" <name> "given" <the group's spec from stalwart.nodes>.
|
|
|
|
Only the two switches the chart interprets carry defaults. affinity,
|
|
nodeSelector, tolerations and topologySpreadConstraints are pass-through: the
|
|
chart never reads their contents, Kubernetes owns their schema, and a default
|
|
would be a value this chart cannot reason about. They therefore arrive here
|
|
unchanged, including absent.
|
|
*/}}
|
|
{{- define "stalwart.groupSettings" -}}
|
|
{{- $defaults := dict
|
|
"spreadAcrossNodes" true
|
|
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
|
|
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s" .group)) -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The topologySpreadConstraints of one node group: the chart's own entry followed
|
|
by the user's, or nothing at all.
|
|
Input: dict "root" <root context> "group" <name> "settings" <stalwart.groupSettings>
|
|
"replicas" <the COMPUTED replica count, as an int>.
|
|
|
|
WHY THE CHART-OWNED ENTRY NEEDS >= 2 REPLICAS. With one pod the constraint is
|
|
satisfied by any placement, so it describes nothing; and the group that ships
|
|
with replicas: 0 ("mx") would carry an object about pods that do not exist. The
|
|
condition is on the COMPUTED count, so scaleDown: true removes it as well. The
|
|
side effect is the one that made this a minor rather than a major change: a
|
|
default install (full: 1, mx: 0) renders byte-for-byte what it rendered before.
|
|
|
|
WHY nodeAffinityPolicy: Ignore IS SET EXPLICITLY, AND IT IS NOT A STYLE CHOICE.
|
|
The two node-inclusion policies have OPPOSITE defaults, straight from the API
|
|
definition: nodeAffinityPolicy defaults to Honor, nodeTaintsPolicy to Ignore.
|
|
Under Honor, only nodes matching the pod's nodeAffinity/nodeSelector count as
|
|
domains - so a nodeSelector narrowing a 2-replica group onto ONE node leaves one
|
|
eligible domain, both pods land in it, the skew is 0, and the constraint is
|
|
satisfied. Both replicas are co-located, nothing is Pending, no event is
|
|
emitted, and the selector in the object is correct - so the CI selector check
|
|
cannot see it either. With Ignore every node counts, the same narrowing gives
|
|
2,0,0, the skew is 2, and the second pod is Pending: the silent half becomes the
|
|
loud one. A render abort was the alternative and was rejected, because it would
|
|
also have forbidden a nodeSelector spanning five nodes, which is legal and
|
|
works.
|
|
|
|
Note what this field does NOT add: the ceiling on replicas per group comes from
|
|
nodeTaintsPolicy being Ignore, and it existed before this value did. values.yaml
|
|
argues the two separately for that reason.
|
|
|
|
WHY A COLLISION ABORTS. Kubernetes keys a spread constraint on
|
|
(topologyKey, whenUnsatisfiable), whose API default is DoNotSchedule - so a
|
|
user entry on kubernetes.io/hostname without an explicit whenUnsatisfiable
|
|
collides with the chart's own. Both ways that collision resolves are worse than
|
|
an abort: `kubectl create` fails validation with a message naming a LIST INDEX
|
|
instead of a value, and an apply-based path merges the two entries over
|
|
patchMergeKey: topologyKey - SILENTLY, dropping one labelSelector and leaving a
|
|
green result. The abort names both values instead, and it only fires while the
|
|
chart's own entry is actually rendered, since without it there is no duplicate.
|
|
*/}}
|
|
{{- define "stalwart.spreadConstraints" -}}
|
|
{{- $root := .root -}}
|
|
{{- $group := .group -}}
|
|
{{- $settings := .settings -}}
|
|
{{- $own := list -}}
|
|
{{- if and $settings.spreadAcrossNodes (ge (int .replicas) 2) -}}
|
|
{{- $own = list (dict
|
|
"maxSkew" 1
|
|
"topologyKey" "kubernetes.io/hostname"
|
|
"whenUnsatisfiable" "DoNotSchedule"
|
|
"nodeAffinityPolicy" "Ignore"
|
|
"labelSelector" (dict "matchLabels" (fromYaml (include "stalwart.groupLabels" (dict "root" $root "group" $group))))) -}}
|
|
{{- end -}}
|
|
{{- $given := $settings.topologySpreadConstraints -}}
|
|
{{- if kindIs "invalid" $given -}}
|
|
{{- $given = list -}}
|
|
{{- end -}}
|
|
{{- if not (kindIs "slice" $given) -}}
|
|
{{- fail (printf "nodes.%s.topologySpreadConstraints must be a list of topology spread constraints or null, got %s (%v)" $group (kindOf $given) $given) -}}
|
|
{{- end -}}
|
|
{{- if $own -}}
|
|
{{- range $i, $constraint := $given -}}
|
|
{{- if not (kindIs "map" $constraint) -}}
|
|
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] must be a mapping, got %s (%v)" $group $i (kindOf $constraint) $constraint) -}}
|
|
{{- end -}}
|
|
{{- $when := $constraint.whenUnsatisfiable | default "DoNotSchedule" -}}
|
|
{{- if and (eq ($constraint.topologyKey | default "") "kubernetes.io/hostname") (eq $when "DoNotSchedule") -}}
|
|
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] collides with the constraint nodes.%s.spreadAcrossNodes renders: both are keyed on topologyKey kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule (the API default, so an omitted whenUnsatisfiable counts), and Kubernetes keys a spread constraint on exactly that pair. Two entries sharing it are rejected by `kubectl create` with a message naming a list index rather than a value, and on an apply-based path they are MERGED over patchMergeKey: topologyKey - silently, so one labelSelector is dropped and the result is green. Pick one: set nodes.%s.spreadAcrossNodes: false and keep your entry (it then owns the per-node distribution of this group, and the chart's nodeAffinityPolicy: Ignore is yours to repeat), or drop your entry and keep the chart's. Entries on any OTHER topologyKey - topology.kubernetes.io/zone, for instance - are appended and really do apply as an AND" $group $i $group $group) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $all := concat $own $given -}}
|
|
{{- if $all -}}
|
|
{{- toYaml $all -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The address of the in-memory store, and the render abort that goes with it.
|
|
Input: the root context. Returns the host, or "" when none is needed.
|
|
|
|
The abort is the point. From v0.16 the cluster coordinator's Default variant
|
|
"reuses the Redis connection already configured on the in-memory store", so
|
|
more than one node without an in-memory store is a cluster with no
|
|
coordination path - and the symptom is not a failed install but a cluster that
|
|
comes up and disagrees with itself. Note what this value does NOT do: it does
|
|
not configure Stalwart. The in-memory store is an object in the database; this
|
|
is the address to type into the WebUI, and the port for the egress rule.
|
|
*/}}
|
|
{{- define "stalwart.valkeyHost" -}}
|
|
{{- $given := "" -}}
|
|
{{- if not (kindIs "invalid" .Values.externalServices) -}}
|
|
{{- $valkey := .Values.externalServices.valkey -}}
|
|
{{- if and (not (kindIs "invalid" $valkey)) (not (kindIs "invalid" $valkey.host)) -}}
|
|
{{- $given = $valkey.host -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $subchartOn := false -}}
|
|
{{- if and (not (kindIs "invalid" .Values.valkeySubchart)) .Values.valkeySubchart.enabled -}}
|
|
{{- $subchartOn = true -}}
|
|
{{- end -}}
|
|
{{- if $given -}}
|
|
{{- $given -}}
|
|
{{- else if $subchartOn -}}
|
|
{{/* The Service our own valkey chart renders:
|
|
{{ .Release.Name }}-{{ .Chart.Name }}-service
|
|
and inside a subchart .Chart.Name is the SUBCHART's name, so this is
|
|
"<release>-valkey-service" with no special case. Reproduced rather than
|
|
included, because a parent cannot call a subchart's define with the
|
|
subchart's context.
|
|
|
|
Note the "-service" suffix: this repo's naming scheme ends every Service
|
|
that way, which is NOT what upstream's valkey chart does (it would be a
|
|
bare "<release>-valkey"). An earlier draft of this chart depended on
|
|
upstream's and carried their name; getting this wrong points the operator
|
|
at an address that does not resolve, and nothing in the chart would say
|
|
so. */}}
|
|
{{- printf "%s-valkey-service" .Release.Name | trunc 63 | trimSuffix "-" -}}
|
|
{{- else -}}
|
|
{{- $total := 0 -}}
|
|
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) -}}
|
|
{{- $total = add $total (int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas))) -}}
|
|
{{- end -}}
|
|
{{- if gt $total 1 -}}
|
|
{{- fail (printf "this release runs %d Stalwart pods, which is a cluster, but no in-memory store has an address: externalServices.valkey.host is empty and valkeySubchart.enabled is false. Stalwart's cluster coordinator rides on the in-memory store connection, so a multi-node install has no coordination path at all - and that failure does not show up as a failed install, it shows up as nodes that do not agree with each other. Set externalServices.valkey.host to an existing instance, or set valkeySubchart.enabled: true to deploy the subchart" $total) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The environment-variable names that config.json references and the StatefulSet
|
|
sets. They are helpers rather than literals in two files because a mismatch
|
|
between the two is not a render error: the server starts, resolves authSecret,
|
|
finds no such variable, and fails to open the data store with
|
|
"Environment variable '<name>' not found". One definition, used by both.
|
|
|
|
Note the upstream resolver treats an EMPTY value as not found, so a 1Password
|
|
item whose field exists but is blank fails exactly like a missing field.
|
|
*/}}
|
|
{{- define "stalwart.env.dbPassword" -}}STALWART_DB_PASSWORD{{- end -}}
|
|
{{- define "stalwart.env.s3AccessKey" -}}STALWART_S3_ACCESS_KEY{{- end -}}
|
|
{{- define "stalwart.env.s3SecretKey" -}}STALWART_S3_SECRET_KEY{{- end -}}
|
|
|
|
{{/*
|
|
The node group the Ingress routes to, validated. Input: the root context.
|
|
*/}}
|
|
{{- define "stalwart.ingressNodeGroup" -}}
|
|
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
|
{{- $group := required "ingress.nodeGroup must name the node group the Ingress routes to" .Values.ingress.nodeGroup -}}
|
|
{{- if not (hasKey $nodes $group) -}}
|
|
{{- fail (printf "ingress.nodeGroup is %q, which is not a key of nodes: - known groups are %s" $group (keys $nodes | sortAlpha | join ", ")) -}}
|
|
{{- end -}}
|
|
{{- $group -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
MTA-STS: the policy path, RFC 8461, hardcoded in one place.
|
|
|
|
NOT A VALUE, and that is a decision rather than an omission. RFC 8461 fixes the
|
|
location - the policy is retrieved over HTTPS from the well-known path
|
|
/.well-known/mta-sts.txt on mta-sts.<domain>. A sending MTA looks there and
|
|
nowhere else, so a values key for it would be a key that can only ever be set
|
|
WRONG, and the failure is silent on this side (see the mtaSts block in
|
|
values.yaml). Both routing modes include this define, so the Ingress path and
|
|
the HTTPRoute path cannot drift apart.
|
|
*/}}
|
|
{{- define "stalwart.mtaSts.path" -}}/.well-known/mta-sts.txt{{- end -}}
|
|
|
|
{{/*
|
|
The validated, normalised mtaSts.hosts list. Input: the root context.
|
|
Returns YAML of a list of dicts with the keys host, tlsSecretName and
|
|
clusterIssuer (the latter two possibly empty), in the order they were written.
|
|
|
|
Shared by BOTH routing modes, which is why the host-level validation lives here
|
|
and not in either template: an entry that is wrong is wrong in both modes, and
|
|
a guard that exists in only one of them is a guard a mode switch removes.
|
|
*/}}
|
|
{{- define "stalwart.mtaSts.hosts" -}}
|
|
{{- $mtaSts := .Values.mtaSts | default dict -}}
|
|
{{- $entries := $mtaSts.hosts | default list -}}
|
|
{{- $out := list -}}
|
|
{{- $seen := dict -}}
|
|
{{- range $i, $entry := $entries -}}
|
|
{{- $entry = $entry | default dict -}}
|
|
{{- $host := required (printf "mtaSts.hosts[%d] has no host - every entry needs the hostname the policy is served on, which RFC 8461 fixes as mta-sts.<domain>" $i) $entry.host -}}
|
|
{{- if hasKey $seen $host -}}
|
|
{{- /*
|
|
An error in BOTH modes, which is why it aborts rather than being de-duplicated.
|
|
In Ingress mode two rules on one host and one path are what ingress-nginx's
|
|
admission webhook rejects - but only once the two land in DIFFERENT objects,
|
|
and whether they do depends on the clusterIssuer of each entry, so the loud
|
|
failure would come and go with an unrelated field. In Gateway mode a repeated
|
|
entry in spec.hostnames is accepted and does nothing at all.
|
|
*/ -}}
|
|
{{- fail (printf "mtaSts.hosts[%d] repeats the host %q, which is already entry %d. One hostname serves one MTA-STS policy; a second entry for it either collides in the ingress-nginx admission webhook or is silently ignored by a Gateway, depending on the mode and on the clusterIssuer fields - so it is refused here instead." $i $host (index $seen $host)) -}}
|
|
{{- end -}}
|
|
{{- $_ := set $seen $host $i -}}
|
|
{{- $out = append $out (dict
|
|
"host" $host
|
|
"tlsSecretName" ($entry.tlsSecretName | default "")
|
|
"clusterIssuer" ($entry.clusterIssuer | default "")) -}}
|
|
{{- end -}}
|
|
{{- toYaml $out -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
MTA-STS in INGRESS MODE: the entries grouped into the Ingress objects that
|
|
actually have to exist. Input: the root context. Returns YAML of
|
|
order: list of clusterIssuer values, first-appearance order ("" included)
|
|
groups: <clusterIssuer> -> dict with
|
|
hosts: list of hosts, one rule each
|
|
tlsOrder: list of TLS secret names, first-appearance order
|
|
tls: <secretName> -> list of hosts sharing it
|
|
|
|
WHY THIS IS NOT ONE INGRESS OBJECT, AND THE REASON IS MEASURED
|
|
|
|
The approved plan on Gitea #123 asks for "one Ingress, one rule per host, tls
|
|
grouped by secret" (point 4) AND for a per-entry, optional clusterIssuer whose
|
|
empty value means no annotation (point 2) AND for an overlay carrying one entry
|
|
with an issuer and one without (point 9). Those three cannot all hold, because
|
|
of where cert-manager reads each of the two fields:
|
|
|
|
- cert-manager.io/cluster-issuer is an ANNOTATION, i.e. OBJECT-scoped. One
|
|
Ingress object carries exactly one of them, or none - and with the
|
|
annotation absent, cert-manager's ingress-shim creates NOTHING for that
|
|
object, for every tls entry on it rather than for some.
|
|
- secretName is TLS-ENTRY-scoped, and the shim creates one Certificate PER
|
|
tls entry.
|
|
|
|
So an object cannot mix "issued by X" with "comes from somewhere else", and the
|
|
split has to be on the issuer. Measured in jk-cluster on 2026-10-13, which is
|
|
also where the second half of the rule comes from:
|
|
|
|
Certificate tls-mta-sts-bekemeyer-klein-de-ingress
|
|
secretName: tls-mta-sts-bekemeyer-klein-de-ingress
|
|
ownerReferences: Ingress/mta-sts-ingress
|
|
Certificate tls-stalwart-stalwart-ingress
|
|
secretName: tls-stalwart-stalwart-ingress
|
|
ownerReferences: Ingress/stalwart-stalwart-ingress
|
|
|
|
The shim names the Certificate AFTER THE SECRET and owns it from the Ingress.
|
|
Two Ingress objects whose tls entries name the same secret therefore drive the
|
|
shim at ONE Certificate name from two owners - so a secret shared by several
|
|
hosts must sit in exactly one object, which is what the tls grouping below is
|
|
for, and a secret may NOT span two issuer groups. That last case is the one
|
|
thing this helper refuses outright.
|
|
|
|
The grouping itself is rss-bridge.tlsGroups (rss-bridge/templates/_helpers.tpl),
|
|
adopted rather than reinvented as #123 point 4 asks; what is new here is the
|
|
issuer dimension, which that chart does not have - it sets one chart-wide
|
|
ingress.clusterIssuer on its single object.
|
|
*/}}
|
|
{{- define "stalwart.mtaSts.ingressGroups" -}}
|
|
{{- $entries := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
|
|
{{- $order := list -}}
|
|
{{- $groups := dict -}}
|
|
{{- $secretIssuer := dict -}}
|
|
{{- range $i, $entry := $entries -}}
|
|
{{- $host := $entry.host -}}
|
|
{{- $issuer := $entry.clusterIssuer -}}
|
|
{{- /*
|
|
REQUIRED HERE AND NOWHERE ELSE. In Gateway mode TLS sits on the Gateway's
|
|
listener and this chart renders no certificate for it, so demanding the field
|
|
there would be demanding a value that does nothing (#123, and the same split as
|
|
Gitea #122). There is deliberately NO DEFAULT: the plausible one - this
|
|
release's own tls-<release>-<chart>-ingress - is the WRONG certificate. It
|
|
covers ingress.domain and not mta-sts.<domain>, and a sending MTA would see a
|
|
name mismatch, give up on the policy and send in the clear, with nothing
|
|
logged on this side.
|
|
*/ -}}
|
|
{{- $secret := required (printf "mtaSts.hosts[%d] (%s) has no tlsSecretName, and in Ingress mode every entry needs one: MTA-STS is fetched over HTTPS only, so a host without a certificate serves no policy at all. There is no default on purpose - this release's own tls-<release>-<chart>-ingress covers ingress.domain, not this host. Point it at the Secret the certificate for %s lives in, and set clusterIssuer as well if cert-manager is the one to request it." $i $host $host) $entry.tlsSecretName -}}
|
|
{{- if hasKey $secretIssuer $secret -}}
|
|
{{- if ne (index $secretIssuer $secret) $issuer -}}
|
|
{{- fail (printf "mtaSts.hosts[%d] (%s) puts the TLS secret %q under clusterIssuer %q, but an earlier entry already uses that secret under clusterIssuer %q. cert-manager's ingress-shim names the Certificate after the secret and owns it from the Ingress object, while the issuer is an object-level annotation - so the two entries would need two Ingress objects both driving one Certificate name, from two owners. Give the two hosts separate secrets, or put them under the same issuer." $i $host $secret $issuer (index $secretIssuer $secret)) -}}
|
|
{{- end -}}
|
|
{{- end -}}
|
|
{{- $_ := set $secretIssuer $secret $issuer -}}
|
|
{{- if not (hasKey $groups $issuer) -}}
|
|
{{- $_ := set $groups $issuer (dict "hosts" list "tlsOrder" list "tls" dict) -}}
|
|
{{- $order = append $order $issuer -}}
|
|
{{- end -}}
|
|
{{- $group := index $groups $issuer -}}
|
|
{{- $_ := set $group "hosts" (append $group.hosts $host) -}}
|
|
{{- if not (hasKey $group.tls $secret) -}}
|
|
{{- $_ := set $group.tls $secret list -}}
|
|
{{- $_ := set $group "tlsOrder" (append $group.tlsOrder $secret) -}}
|
|
{{- end -}}
|
|
{{- $_ := set $group.tls $secret (append (index $group.tls $secret) $host) -}}
|
|
{{- end -}}
|
|
{{- toYaml (dict "order" $order "groups" $groups) -}}
|
|
{{- end -}}
|
|
|
|
{{/*
|
|
The name of the MTA-STS Ingress object for one clusterIssuer group. Input: dict
|
|
"root" <root context> "issuer" <the group's clusterIssuer, possibly "">.
|
|
|
|
NOT AN INDEX, and the reason is the one exception the comparable precedent
|
|
names itself. homeassistant carries the entry's index in its HTTPRoute names
|
|
(Gitea #114, group 3) and writes down that reordering the list therefore
|
|
recreates rather than edits the objects - "harmless (a route holds no state or
|
|
certificate)". These objects DO hold a certificate: cert-manager owns the
|
|
Certificate from the Ingress, so a rename deletes one Certificate and creates
|
|
another. The Secret survives that - cert-manager does not delete it with the
|
|
Certificate - so no re-issue follows and nothing breaks, but it is churn on an
|
|
ACME account in return for a reordered list, and the issuer is a stable key
|
|
that costs nothing to use instead.
|
|
|
|
The issuer-less group takes the unsuffixed name, and that cannot collide with
|
|
an issuer's: a clusterIssuer name is never the empty string, so no suffix is
|
|
ever empty.
|
|
*/}}
|
|
{{- define "stalwart.mtaSts.ingressName" -}}
|
|
{{- $base := printf "%s-mta-sts" (include "stalwart.fullname" .root) -}}
|
|
{{- if .issuer -}}
|
|
{{- printf "%s-%s-ingress" $base .issuer -}}
|
|
{{- else -}}
|
|
{{- printf "%s-ingress" $base -}}
|
|
{{- end -}}
|
|
{{- end -}}
|