validate-charts.yml / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Blocked by required conditions
validate-charts.yml / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Blocked by required conditions
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 6s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 22s
Validate Helm Charts / Install-test (pull_request) Successful in 2m22s
Install-test / Install-test stalwart (pull_request) Successful in 2m22s
Cluster-capable from the start, with roles rather than symmetry: one StatefulSet per entry of the "nodes" map, the key passed in STALWART_ROLE. Since v0.16 Stalwart keeps its whole configuration inside the data store and manages it over JMAP, so the chart's scope is deliberately narrow: it renders config.json (exactly the DataStore object), the workloads, the Services, the NetworkPolicies, the Ingress and a cert-manager Certificate. Listeners, blob store, in-memory store, TLS object and cluster roles are operator work in the WebUI, which is why NOTES.txt carries an eight-step first install. config.json stays secret-free: authSecret uses the EnvironmentVariable variant (verified against the serde definition of PostgreSqlStore at v0.16.24), so the password arrives through secretKeyRef and checksum/config stays meaningful. The username cannot follow it - authUsername is a plain String with no secret form - so it is a public value and says so. Services are per PORT, not per group: a NodePort is cluster-unique, so "full" and "mx" can only accept SMTP behind one Service, selected through a per-port pod label. The http port gets a per-group ClusterIP Service instead, so the Ingress reaches exactly ingress.nodeGroup. Four traps, documented rather than explained away: - The role lookup FAILS OPEN. A node whose ClusterRole is missing from the database enables every listener and every task while staying Running with a green probe, and the log line arrives after the listeners are bound. Hence mx at replicas 0, per-port Services, and a NetworkPolicy that is a ceiling independent of what was bound. - NET_BIND_SERVICE is a documented deviation needed to EXEC, not to bind. The image sets setcap cap_net_bind_service=+ep, which makes the kernel fail execve when the bounding set is empty. The values comment says both why it is there and that it is never used, because the second half is what stops the next hardening pass from removing it. - externalTrafficPolicy must be Cluster - no pod runs on a control-plane node while the HA proxy delivers to them, so Local would drop every packet - and the source address that costs is restored only by the HA proxy's PROXY v2 header. - /healthz is rate-limited to 100 requests/60s per source IP, which is a node IP for kubelet probes, so all pods on a node share one bucket. The probe intervals are calculated (6 req/min/pod, ~16 pods per node) and the sum is a comment in values.yaml. A first install needs bootstrap.enabled: true: the setup wizard appears only when config.json is ABSENT, and this chart always renders one, so recovery mode is upstream's documented path for a templated config.json. Second chart in the repo with a dependency - the Valkey project's own chart, off by default behind condition:, because Stalwart's cluster coordinator rides on the in-memory store connection. The render aborts when more than one pod runs with no in-memory store address anywhere. Also wired up: publish-stalwart.yml through the shared block in workflow-util/pipelines, the change-app-version.yml choice list, a Renovate customManagers block anchored on the v prefix (which excludes the moving latest / v0.16 / -alpine tags), CI fixtures, and the README and CLAUDE.md lines that claimed ytdlp-web-player was the only chart with a dependency. Verified locally: ci/helm-dependencies.sh, helm lint --strict (0 failed), helm package, helm template against chart defaults and against ci/stalwart/test-values.yaml, the rendered config.json parsed as JSON with the field types the serde definition requires, ci/run-quantity-render-test.sh (0 failed, both nodes.*.resources paths exercised), NOTES.txt rendered through a scratch ConfigMap, and the null-safety and fail paths (securityContext=null keeps NET_BIND_SERVICE; a cluster without an in-memory store, an unknown port name, a group without http and an unknown ingress.nodeGroup all abort with a named message; the schema rejects timeout, a string timeoutMs and a list-shaped nodes). NO runtime verification was possible - there is no k3d or Docker daemon in this workspace. Part of #65
137 lines
5.8 KiB
YAML
137 lines
5.8 KiB
YAML
{{- /*
|
|
One NetworkPolicy per node group.
|
|
|
|
This object is the CEILING ON REACHABILITY, and that is its real job here: it
|
|
holds no matter which ports the process actually bound. A node whose
|
|
ClusterRole is missing from the database fail-opens into a full node and binds
|
|
every listener - including IMAP and the admin interface on a group meant to
|
|
take SMTP only - while staying Running with a green probe. The per-port
|
|
Services publish only what was asked for, and this policy is the second,
|
|
independent limit. See values.yaml for the source-level detail.
|
|
|
|
!!! THE PORT NUMBERS HERE ARE POD PORTS (25/465/993/8080), NOT NODEPORTS !!!
|
|
|
|
This is the easiest mistake to make in this chart, so it is spelled out. A
|
|
NetworkPolicy is evaluated at the POD's network interface, after kube-proxy has
|
|
already translated the NodePort to the targetPort. Writing 30025 here would
|
|
match nothing: the packet arrives at the pod on 25. The policy would then look
|
|
strict, be accepted by the API server, and silently block every inbound
|
|
connection - or, if it is the only rule, block the lot.
|
|
|
|
Egress is restricted BY PORT and not by destination, because a NetworkPolicy
|
|
matches CIDRs and selectors and never hostnames - so dataStore.postgres.host
|
|
and externalServices.*.host cannot appear in it. What it still buys: a
|
|
compromised pod cannot open arbitrary outbound ports.
|
|
|
|
And a NetworkPolicy is only a boundary where the cluster's CNI enforces one. An
|
|
unenforced policy is accepted and filters nothing.
|
|
*/ -}}
|
|
{{- if .Values.networkPolicy.enabled }}
|
|
{{- $root := . -}}
|
|
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
|
{{- $egress := .Values.networkPolicy.egress -}}
|
|
{{- $probeSources := .Values.networkPolicy.probeSources | default list -}}
|
|
{{- $allowedNs := .Values.networkPolicy.allowedNamespaces | default list -}}
|
|
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
|
|
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: {{ $name }}-networkpolicy
|
|
labels:
|
|
app: {{ $name }}
|
|
spec:
|
|
podSelector:
|
|
matchLabels:
|
|
app: {{ $name }}-sfs
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress:
|
|
{{- /* The public mail ports: open to any source. They have to be - the
|
|
senders are the whole internet, and after NodePort masquerading the
|
|
source address is a control-plane node anyway. */}}
|
|
{{- $publicPorts := list }}
|
|
{{- range $portName := $spec.exposedPorts }}
|
|
{{- $port := index $ports $portName }}
|
|
{{- if $port.public }}{{- $publicPorts = append $publicPorts $portName }}{{- end }}
|
|
{{- end }}
|
|
{{- if $publicPorts }}
|
|
- ports:
|
|
{{- range $portName := $publicPorts }}
|
|
{{- $port := index $ports $portName }}
|
|
# {{ $portName }}: published on nodePort {{ int $port.nodePort }}, reaching the pod on {{ int $port.containerPort }}
|
|
- protocol: {{ $port.protocol | default "TCP" }}
|
|
port: {{ int $port.containerPort }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- /* The http port: probes plus the ingress controller. */}}
|
|
{{- $http := index $ports "http" }}
|
|
- ports:
|
|
- protocol: {{ $http.protocol | default "TCP" }}
|
|
port: {{ int $http.containerPort }}
|
|
{{- if or $probeSources $allowedNs }}
|
|
# Restricted because networkPolicy.probeSources or .allowedNamespaces is
|
|
# set. MIND THE KUBELET: probes come from the IP of the worker node
|
|
# running the pod, so that address has to be in probeSources or the pod
|
|
# never becomes Ready - and a pod that is never Ready reads as a broken
|
|
# application rather than as a policy.
|
|
from:
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
# Set by Kubernetes itself on every namespace (>= 1.21), so this
|
|
# needs no labelling work.
|
|
kubernetes.io/metadata.name: {{ $root.Release.Namespace }}
|
|
{{- range $allowedNs }}
|
|
- namespaceSelector:
|
|
matchLabels:
|
|
kubernetes.io/metadata.name: {{ . | quote }}
|
|
{{- end }}
|
|
{{- range $probeSources }}
|
|
- ipBlock:
|
|
cidr: {{ . | quote }}
|
|
{{- end }}
|
|
{{- else }}
|
|
# NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside
|
|
# the cluster: http carries no nodePort, so nothing outside reaches it
|
|
# through this chart. The alternative was to guess the worker node CIDRs,
|
|
# and a wrong guess blocks every kubelet probe. Narrow it by setting
|
|
# networkPolicy.probeSources (and then .allowedNamespaces for the
|
|
# ingress controller, or the Ingress goes dark).
|
|
{{- end }}
|
|
egress:
|
|
{{- if $egress.dns }}
|
|
- ports:
|
|
- protocol: UDP
|
|
port: 53
|
|
- protocol: TCP
|
|
port: 53
|
|
{{- end }}
|
|
{{- if $egress.smtp }}
|
|
# Outbound delivery: port 25 on any host on the internet. Uniform for every
|
|
# group on purpose - the chart does not know which groups run the
|
|
# outboundMta task, the value would drift away from the database, and the
|
|
# failure mode of getting it wrong is mail silently not leaving.
|
|
- ports:
|
|
- protocol: TCP
|
|
port: 25
|
|
{{- end }}
|
|
{{- $outPorts := list (int $root.Values.dataStore.postgres.port) }}
|
|
{{- with $root.Values.externalServices }}
|
|
{{- if .s3 }}{{- $outPorts = append $outPorts (int .s3.port) }}{{- end }}
|
|
{{- if .valkey }}{{- $outPorts = append $outPorts (int .valkey.port) }}{{- end }}
|
|
{{- end }}
|
|
{{- range $root.Values.networkPolicy.egress.extraPorts }}
|
|
{{- $outPorts = append $outPorts (int .) }}
|
|
{{- end }}
|
|
# The data store, the blob store, the in-memory store and anything added
|
|
# through networkPolicy.egress.extraPorts. Ports only - see the header.
|
|
- ports:
|
|
{{- range $outPorts | uniq }}
|
|
- protocol: TCP
|
|
port: {{ . }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- end }}
|