Files
helm-charts/stalwart/values.schema.json
T
jklein 26be67f4cd
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
feat(stalwart): new chart for the Stalwart mail server
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
2026-10-02 19:13:49 +00:00

503 lines
25 KiB
JSON

{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "stalwart values",
"description": "Values schema for the stalwart chart (#68, guideline 12). additionalProperties is false on every chart-owned object level, so a misspelled key fails the render instead of being silently ignored. Three kinds of object stay open: the ones passed verbatim into the pod spec (probes, security contexts, free-form annotation maps), because Kubernetes owns their schema; the whole valkey block, because that schema belongs to the subchart; and 'global'. Two objects are user-extensible MAPS (ports, nodes) - there additionalProperties carries the value schema rather than false, which keeps a new entry legal and its contents strict. Values with no valid default (ingress.domain, ingress.clusterIssuer, dataStore.postgres.host, the secrets.*SecretRef paths) are enforced by required() in the templates, not here: they ship as null, so a JSON Schema 'required' array could never fire against them (#18).",
"type": "object",
"additionalProperties": false,
"properties": {
"global": {
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it - and with a subchart present that is not hypothetical: whatever is set here reaches valkey too.",
"type": "object"
},
"scaleDown": {
"description": "true renders every node group's StatefulSet with replicas: 0 and overrides every nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay in place, so no certificate is re-issued.",
"type": "boolean"
},
"limitFactor": {
"description": "Chart-wide default limit factor for every node group; a group's resources.limitFactor wins over it.",
"type": ["number", "null"],
"exclusiveMinimum": 0
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"dataStoreSecretRef": {
"description": "op:// path of the item carrying the field 'password'. Only the password: authUsername is a plain String on the DataStore object with no secret form, so the username is the public value dataStore.postgres.username.",
"type": ["string", "null"]
},
"recoveryAdminSecretRef": {
"description": "op:// path of the item carrying the field 'recovery-admin' with the complete 'admin:<password>' value of STALWART_RECOVERY_ADMIN. null switches the variable off, which is the correct end state once a permanent administrator exists.",
"type": ["string", "null"]
},
"s3SecretRef": {
"description": "op:// path of the item carrying 'access-key' and 'secret-key'. Exposed to the container as environment variables for the BlobStore object in the database to reference; the chart itself does not configure the blob store.",
"type": ["string", "null"]
}
}
},
"dataStore": {
"description": "The only part of Stalwart this chart configures: the contents of config.json, which is exactly the DataStore object.",
"type": "object",
"additionalProperties": false,
"properties": {
"postgres": {
"type": "object",
"additionalProperties": false,
"properties": {
"host": {
"description": "REQUIRED (enforced by required() in the template).",
"type": ["string", "null"]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"database": {
"type": "string",
"minLength": 1
},
"username": {
"description": "Rendered into the ConfigMap as authUsername. Public by necessity - see secrets.dataStoreSecretRef.",
"type": "string",
"minLength": 1
},
"tls": {
"type": "object",
"additionalProperties": false,
"properties": {
"enable": {
"description": "Rendered as the object's useTls field. Defaults to true here, the opposite of upstream's default.",
"type": "boolean"
},
"allowInvalidCerts": {
"description": "Accepting an invalid certificate makes TLS obfuscation rather than protection. Only ever a temporary diagnostic.",
"type": "boolean"
}
}
},
"poolMaxConnections": {
"type": "integer",
"minimum": 1,
"maximum": 8192
},
"timeoutMs": {
"description": "MILLISECONDS as an integer, rendered into config.json as the 'timeout' field. Upstream's object encoding: 'A duration is an integer count of milliseconds... Neither accepts a string.' The key is named for its unit so that a '15s' is rejected here rather than by the server at startup. Integer-typed on purpose: a string form cannot be accepted at all.",
"type": "integer",
"minimum": 1
},
"options": {
"description": "Extra libpq connection options, passed through verbatim. null omits the field.",
"type": ["string", "null"]
}
}
}
}
},
"bootstrap": {
"description": "Recovery mode, which a first install needs because this chart always renders config.json and the bootstrap wizard only appears when that file is absent.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Sets STALWART_RECOVERY_MODE=1: management HTTP on 8080 only, every mail port closed. true for the first install, then false. Leaving it on keeps the mail ports shut while every Kubernetes object looks healthy.",
"type": "boolean"
},
"logLevel": {
"description": "STALWART_RECOVERY_MODE_LOG_LEVEL. No effect once bootstrap.enabled is false. Not enumerated: the accepted set is upstream's tracing level parser, which this chart has not pinned down.",
"type": "string",
"minLength": 1
}
}
},
"initImage": {
"description": "The init container that seeds config.json into the writable config volume. An auxiliary image, so it is pinned here rather than taken from appVersion (guideline 3).",
"type": "object",
"additionalProperties": false,
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": "string",
"minLength": 1
}
}
},
"ports": {
"description": "Port name -> port spec. A user-extensible map, so additionalProperties carries the value schema instead of false: adding 587 or 4190 is a legal, additive change. containerPort is what the application binds (and what the NetworkListener object in the database must say); nodePort is what the cluster publishes. They are different numbers here and are not interchangeable.",
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["containerPort"],
"properties": {
"containerPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"nodePort": {
"description": "Required while public is true (checked in the template). Must be inside the API server's --service-node-port-range, default 30000-32767.",
"type": ["integer", "null"],
"minimum": 1,
"maximum": 65535
},
"protocol": {
"type": "string",
"enum": ["TCP", "UDP", "SCTP"]
},
"public": {
"description": "true renders a NodePort Service for this port, shared by every node group that exposes it.",
"type": "boolean"
}
}
}
},
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"externalTrafficPolicy": {
"description": "Enumerated, and Local is admissible only in a topology where a pod runs on every node that receives mail. In this deployment no Stalwart pod is scheduled on a control-plane node while the HA proxy delivers to the control-plane nodes, so Local would drop every packet - not some. See values.yaml.",
"type": "string",
"enum": ["Cluster", "Local"]
},
"annotations": {
"description": "Extra annotations for every Service this chart renders. Free-form keys by nature, so this object stays open.",
"type": "object"
}
}
},
"nodes": {
"description": "Cluster-role name -> node group. A MAP and not a list, so that an override never depends on a position: with a list, inserting a group would silently repoint every existing --set nodes[n]... at the wrong one. The key is passed in STALWART_ROLE and a ClusterRole object of exactly that name must exist in the database - if it does not, the node fail-opens into a full node with every listener AND every task, while staying Running with a green probe. There is deliberately no 'tasks' key: that is a property of the database object and a values key that did nothing would be worse than its absence.",
"type": "object",
"minProperties": 1,
"$comment": "The two groups the chart ships are spelled out under properties rather than left to additionalProperties alone, and that is not redundancy: ci/run-quantity-render-test.sh discovers a chart's resources blocks by walking 'properties' only, so a resources block reachable solely through additionalProperties is invisible to it - and the test then fails the chart for declaring none while its templates call the helper. Writing them out gives that test two real --set paths (nodes.full.resources, nodes.mx.resources). additionalProperties keeps a user-added group legal and equally strict. Keep the three in sync; resources and env are $refs precisely to limit how far they can drift.",
"properties": {
"full": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"description": "0 is allowed and scales the group down; the chart-global scaleDown wins over it.",
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
},
"mx": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"description": "Ships as 0. Raising it before the 'mx' ClusterRole exists in the database produces a SECOND FULL NODE rather than an SMTP-only one, because a node whose role is not found enables every listener and every task.",
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
}
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["exposedPorts"],
"properties": {
"replicas": {
"type": "integer",
"minimum": 0
},
"exposedPorts": {
"$ref": "#/definitions/exposedPorts"
},
"resources": {
"$ref": "#/definitions/resources"
},
"env": {
"$ref": "#/definitions/env"
}
}
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "On by default. Only a real boundary where the cluster's CNI enforces NetworkPolicies; an unenforced policy is accepted by the API server and filters nothing.",
"type": "boolean"
},
"probeSources": {
"description": "Source CIDRs allowed to reach the http port, where the kubelet's probe traffic has to be permitted - probes come from the IP of the worker node running the pod. EMPTY MEANS OPEN (the rule renders without a 'from' selector), which is the deliberate choice: a guessed CIDR that misses the real node addresses blocks every probe, and a pod that never becomes Ready reads as a broken application rather than as a policy.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"allowedNamespaces": {
"description": "Namespaces allowed to reach the http port in addition to the release's own, matched on the kubernetes.io/metadata.name label Kubernetes sets itself. The ingress controller's namespace belongs here as soon as probeSources is non-empty, or the Ingress goes dark. A name that does not exist matches nothing and is silently ineffective.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"egress": {
"description": "Egress is restricted by PORT, not by destination: a NetworkPolicy matches CIDRs and selectors and never hostnames, so no host value from this file can appear in it.",
"type": "object",
"additionalProperties": false,
"properties": {
"dns": {
"type": "boolean"
},
"smtp": {
"description": "Outbound port 25 to any destination, required for delivery. Uniform for every group on purpose - the chart does not know which groups run the outboundMta task, and the failure mode of guessing is mail silently not leaving.",
"type": "boolean"
},
"extraPorts": {
"description": "Additional outbound TCP ports beyond the data store, S3 and Valkey ports the chart derives. Add 443 for outbound HTTPS (DNS-01 provider APIs, AI model endpoints, remote lookup lists).",
"type": ["array", "null"],
"items": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
}
}
}
},
"externalServices": {
"description": "KUBERNETES ONLY - these do not configure Stalwart. The blob store and the in-memory store are objects in the database. The chart uses the ports for the egress rules, the valkey host to decide whether the in-memory store has an address at all, and both hosts to print the WebUI checklist in NOTES.txt.",
"type": "object",
"additionalProperties": false,
"properties": {
"s3": {
"$ref": "#/definitions/externalService"
},
"valkey": {
"$ref": "#/definitions/externalService"
}
}
},
"certificate": {
"description": "The cert-manager Certificate for the MAIL ports. The Ingress certificate covers only the WebUI and JMAP; the HA proxy terminates no TLS, so Stalwart presents this one itself for STARTTLS on 25 and implicit TLS on 465/993. This object only obtains the files - pointing Stalwart at them is a TLS object in the database.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"clusterIssuer": {
"description": "null falls back to ingress.clusterIssuer. Usually a DIFFERENT issuer: the same reasoning that rules out Stalwart's own ACME rules out HTTP-01 here, because port 80 does not reach this cluster for the mail hostname, so a DNS-01 issuer is normally required.",
"type": ["string", "null"]
},
"extraDnsNames": {
"description": "Additional names for the mail certificate; ingress.domain is always included. For the hostnames other MTAs and clients actually connect to when they differ from the WebUI host.",
"type": ["array", "null"],
"items": {
"type": "string",
"minLength": 1
}
},
"mountPath": {
"description": "Where the issued certificate is mounted read-only. A top-level path so no mount is nested inside the config emptyDir. The TLS object in the database points at <mountPath>/tls.crt and <mountPath>/tls.key.",
"type": "string",
"minLength": 1
}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
"type": "boolean"
},
"clusterIssuer": {
"description": "REQUIRED (enforced by required() in the template).",
"type": ["string", "null"]
},
"domain": {
"description": "REQUIRED (enforced by required() in the template), and read in two places - the Ingress host and the mail Certificate.",
"type": ["string", "null"]
},
"className": {
"type": ["string", "null"]
},
"nodeGroup": {
"description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
"type": "string",
"minLength": 1
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
"type": "object"
}
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults; open because the chart does not interpret the individual fields. seccompProfile lives here so it also covers the init container.",
"type": ["object", "null"]
},
"container": {
"description": "Merged onto the chart's container hardening defaults and applied to BOTH the server and the config-seeding init container; open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
"readinessProbe": {
"$ref": "#/definitions/probe"
},
"startupProbe": {
"$ref": "#/definitions/probe"
},
"valkey": {
"description": "Values of the valkey subchart (the Valkey project's own chart), passed straight through. Open on purpose and at every level: this chart owns neither the keys nor their meaning, and a closed copy would reject whatever the subchart adds in its next release - the exact failure a schema exists to prevent, pointed at the wrong chart. This chart reads exactly one of them, 'enabled', which is the dependency condition. Two of upstream's defaults fall short of this repo's conventions and are documented in values.yaml rather than patched here.",
"type": "object"
}
},
"definitions": {
"exposedPorts": {
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"externalService": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"host": {
"description": "For valkey, empty plus valkey.enabled resolves to the subchart's Service; empty with the subchart off and more than one Stalwart pod aborts the render, because Stalwart's cluster coordinator rides on the in-memory store connection. Does not point Stalwart at anything by itself.",
"type": ["string", "null"]
},
"port": {
"description": "Used for the egress rule of every NetworkPolicy.",
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
},
"resources": {
"description": "May be null / empty: the chart then falls back to that GROUP's default requests and computed limits (#99) instead of rendering a container without resource accounting. The per-group fallback matters here - 'mx' is deliberately lighter than 'full'.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"limitFactor": {
"type": "number",
"exclusiveMinimum": 0
},
"requests": {
"$ref": "#/definitions/resourceList"
},
"limits": {
"$ref": "#/definitions/resourceList"
}
}
},
"resourceList": {
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"cpu": {
"type": ["string", "number", "null"]
},
"memory": {
"type": ["string", "number", "null"]
},
"ephemeral-storage": {
"type": ["string", "number", "null"]
},
"ephemeralStorage": {
"type": ["string", "number", "null"]
}
}
},
"env": {
"description": "Additive environment entries for a node group's container. Additive means additive: an entry repeating a name the chart already sets does not replace it - at install both reach the pod and the later one wins, but on an existing release Kubernetes merges env by name, the pair collapses to the chart's own value, and helm upgrade still reports success.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
},
"valueFrom": {
"type": "object",
"additionalProperties": false,
"properties": {
"secretKeyRef": {
"$ref": "#/definitions/keyRef"
},
"configMapKeyRef": {
"$ref": "#/definitions/keyRef"
}
}
}
}
}
},
"keyRef": {
"type": "object",
"additionalProperties": false,
"required": ["name", "key"],
"properties": {
"name": {
"type": "string"
},
"key": {
"type": "string"
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (#99) rather than the probe disappearing. The defaults carry the probe's CONTEXT - the /healthz paths, the port name AND the calculated intervals: Stalwart rate-limits anonymous /healthz requests to 100 per 60s per SOURCE IP, probes arrive from the node IP, so every pod on a node shares one bucket. 20/20/10 costs 6 requests/min/pod and leaves room for roughly 16 pods per node; 10/10/5 would allow about 8. Tightening these produces 429s, and a 429 on liveness is a restart loop.",
"type": ["object", "null"]
}
}
}