Files
helm-charts/stalwart/templates/_helpers.tpl
T
jklein 6ade74db80
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 7s
SonarQube Analysis / SonarQube Analysis (push) Successful in 27s
feat(stalwart): spread each node group over nodes and give each one a PDB
A two-replica node group was redundancy on paper: nothing kept both pods off
one node, and no PodDisruptionBudget kept a drain from taking both at once.
Both objects are now rendered per node group, gated on that group's COMPUTED
replica count being >= 2.

The distribution is a promise, not an effort: maxSkew 1 on
kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only
this group's own pods, so an "mx" and a "full" pod may share a node. The
selector comes from the chart (stalwart.groupLabels), never from the operator -
a spread constraint whose selector matches no pod is trivially satisfied,
distributes nothing and reports nothing.

nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing
detail. Its API default is Honor, under which a narrowing nodeSelector shrinks
the set of counted domains until both replicas fit into one: skew 0, both pods
co-located, no Pending, no event, and a selector that is correct - so no check
can see it. With Ignore the same narrowing gives skew 2 and the second pod is
Pending. A render abort was the alternative and would also have forbidden a
nodeSelector spanning five nodes, which is legal.

A user entry colliding with the chart's own on (topologyKey,
whenUnsatisfiable) aborts the render naming both values, because the API
rejects the duplicate with a message about a list index and an apply-based path
merges the two over patchMergeKey: topologyKey silently, dropping one selector.

Minor rather than major, proved with a diff rather than argued: with the
shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml,
helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both
diffs empty.

New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds
every rendered PodDisruptionBudget selector and every topologySpreadConstraints
labelSelector against the labels of the pod templates in the same render. Helm
itself is the YAML parser (ci/yaml2json), because the runners have neither
pyyaml nor yq and a hand-written parser could read nothing and report nothing.
podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors
are reported as not checked rather than counted as passing. Two anti-vacuum
guards: a chart with an overlay under ci/selector-match/ that renders no
selector fails, and a run that checks no selector at all fails.

Fixes #96
2026-10-07 19:36:04 +00:00

559 lines
27 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 -}}