Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 29s
Install-test / Install-test stalwart (pull_request) Successful in 1m42s
Validate Helm Charts / Install-test (pull_request) Successful in 1m42s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 13s
SonarQube Analysis / SonarQube Analysis (push) Successful in 50s
Maintainer's decision, and it reverses this chart's naming rather than the
style guide's rule. The objects become
<release>-stalwart-mta-sts-ingress, -1, -2, ...
in this repo's idiom ({{ if $i }}-{{ $i }}{{ end }}), the same as
homeassistant's routes and keycloak's realm Ingresses, inlined in the
template instead of behind a helper of its own.
WHY IT GOES THIS WAY. This chart named by issuer, reading the style guide's
index rule as resting on "a route holds no state and no certificate" and
observing that these objects hold one. That reading was right and the
rule's STATED REASON was wrong: the parallel #122 round named by index,
cited the same rule as binding, and entered keycloak into it - whose realm
Ingresses hold certificates too. Two charts did opposite things for one
shape, and the rule had been extended onto cases its own precondition
excluded. #125 is merged and published, so the reason is repaired and the
naming unified on index; keycloak is not touched.
What actually carries the index rule, in three steps that have different
standing and are written as such in the template header and the style guide:
cert-manager here runs WITHOUT --enable-certificate-owner-ref (MEASURED by
the maintainer - absent from the Deployment's args and from the
ControllerConfiguration its --config names, which carries only
kind: ControllerConfiguration and enableGatewayAPI: true); by that default
the Certificate is collected with its Ingress but the SECRET IS NOT
(DOCUMENTED behaviour, not measured here), so a rename recreates the
Certificate object against a surviving secret and issues nothing; and NO
RENAME WAS APPLIED against a cluster. The promise therefore hangs on a
cluster-level flag this repository does not control - with it on, a reorder
would cost a fresh ACME request per renumbered object.
UNCHANGED: the structure. One Ingress per distinct clusterIssuer, one rule
per host, tls grouped by secret, and the abort when one secret spans two
issuers - grouping is a cert-manager question and naming is a style
question, and they are independent.
TWO THINGS THE RENAME OBLIGES.
1. NOTES.txt now prints the mapping - index, object name, issuer, secrets
and hosts per object, the way keycloak's does for its realms - because
the name no longer carries the issuer and nothing else in the cluster
says which object holds which.
2. The reordering hazard is documented as SHARPER than homeassistant's:
there an index names a host, here it names an issuer GROUP derived from
the list, so moving one entry can change the grouping and renumber
objects that were not themselves moved.
AN ASYMMETRY WITH keycloak, named in the template header: there both modes
render one object per entry, so the index question arises twice. Here it
arises on the Ingress side ONLY - Gateway mode renders one HTTPRoute
carrying every hostname, because TLS is none of the route's business, so
there is no index and no name to go stale.
Version stays 1.3.0+upv0.16.25: nothing is published yet, so nothing is
being renamed in a release.
Re-measured after the rename, not assumed: three empty helm template diffs
against origin/main (defaults, CI fixture, overlay), helm lint --strict
clean three ways, every abort path still firing with the structural one
still winning over the per-entry one, the schema still rejecting
mtaSts.enabled, and the HTTPRoute render test still at 64 assertions over
22 charts - object names are not assertions.
718 lines
35 KiB
Smarty
718 lines
35 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 -}}
|
|
|
|
{{/*
|
|
NOTE ON THE OBJECT NAMES, because this is where an earlier draft of this chart
|
|
disagreed with the rest of the repository and lost.
|
|
|
|
The MTA-STS Ingress objects are named BY INDEX over the issuer groups -
|
|
"<release>-stalwart-mta-sts-ingress", then "-1", "-2" - inlined in
|
|
stalwart.ingress.yaml in the same idiom as homeassistant's routes and
|
|
keycloak's realm Ingresses ({{ if $i }}-{{ $i }}{{ end }}), rather than by a
|
|
helper of their own.
|
|
|
|
An earlier draft named them after the clusterIssuer, arguing that the style
|
|
guide's index rule rested on "a route holds no state and no certificate" and
|
|
that these objects hold one. The reading of the rule was right and the rule's
|
|
STATED REASON was wrong - that sentence has been corrected in the style guide
|
|
(section 4) rather than the naming of two charts, because keycloak's realm
|
|
Ingresses hold certificates too and are already published. What actually
|
|
carries the index rule is that THE SECRET SURVIVES a rename, so no certificate
|
|
is re-issued; the full three-step version, including which step is measured and
|
|
which is only documented behaviour, is in that section and in the header of
|
|
stalwart.ingress.yaml.
|
|
*/}}
|