Files
helm-charts/stalwart/values.schema.json
T
jklein 3e969c5659 feat(stalwart): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. ONE route - not one per node group. #114 describes
this chart as rendering "per node group"; its SERVICES and StatefulSets are,
the routing object is not and must not be. The route targets exactly
ingress.nodeGroup's own http Service and keeps both of the Ingress's guards,
because the WebUI's setup and management screens are not safe to spread over a
group that may not run the management tasks. ingress.nodeGroup is therefore
shared by both modes: it names where the management surface is published from,
which is not a property of the Ingress.

THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
anything of the TLS path does. stalwart.certificate.yaml covers STARTTLS on 25
and implicit TLS on 465/993; those connections pass through neither an ingress
controller nor a Gateway. So in Gateway mode ingress.domain is still required
(the certificate's dnsNames read it), ingress.clusterIssuer is still required
unless certificate.clusterIssuer is set, and the Certificate must NOT be
removed as part of a migration - deleting it drops SMTP and IMAP back to
Stalwart's self-signed certificate, with clients seeing a name mismatch while
every Kubernetes object stays green. Verified in the render: Gateway mode emits
Certificate + HTTPRoute and no Ingress.

WHAT THIS MODE DROPS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
all of it operator-supplied through ingress.annotations (this chart sets none
of it):

    enable-cors: true
    cors-allow-origin:  https://webmail.bekemeyer-klein.de
    cors-allow-methods: GET, POST, OPTIONS
    cors-allow-headers: Authorization, Content-Type

That origin is BULWARK, the webmail chart in this same group. Without the CORS
configuration the browser-side webmail cannot call this server's JMAP endpoint
at all, and the failure is entirely client-side: a console error, nothing in
the server's logs.

AND "no counterpart" WOULD HAVE BEEN WRONG. Measured by grepping the CRDs this
repo pins (gateway-api v1.6.3 standard channel, ci/gateway-api-crds.sh) rather
than inferred: HTTPRoute's filters carry a full 'cors' field (GEP-1767) with
allowOrigins/allowMethods/allowHeaders/allowCredentials/maxAge, and its
documented level - verbatim from that CRD - is "Support: Extended".
responseHeaderModifier is in the same CRD and is also Extended, not Core.

The chart still does not render it, for three reasons in order:

  1. The chart has NO CORS values. The live configuration is the operator's,
     and the chart sets no CORS annotations in Ingress mode either - so
     rendering a filter means inventing a values surface that would exist in
     Gateway mode only. That asymmetry is its own defect and its own work.
  2. Extended support is not support. A rejected route is rejected WHOLE,
     which would take the WebUI and JMAP down together (the reasoning #119
     reached for statusCode); and the observed failure is quieter - Cilium
     accepts filters.type: CORS and forwards the preflight upstream anyway
     (cilium/cilium#45272), i.e. Accepted: True with the behaviour absent.
  3. Half a CORS is worse than none, and the translatable half is the useless
     one HERE. A responseHeaderModifier can add the Access-Control-Allow-*
     headers; the PREFLIGHT has to be answered BY THE GATEWAY, which the CORS
     filter's own contract states and a bare header filter cannot do. Because
     the allowed headers include Authorization, EVERY webmail request is
     preflighted - so the expressible half is exactly the half that does not
     help.

Named, not rebuilt (decision 2 of #114), in values.yaml, in the schema and in
the Gateway branch of NOTES.txt, with the operational instruction: configure
CORS at the Gateway with that implementation's own support BEFORE moving this
host, and test it from the webmail in a browser - curl sends no preflight, so
it passes while the webmail fails.

NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) where it
asked ingress.enabled, and two paragraphs that named the ingress controller
specifically now name the Gateway in that mode: the auto-ban step (switching
API does not avoid or reset it - one proxy address instead of another) and the
NetworkPolicy paragraph, where the namespace to allow is the Gateway's data
plane rather than an ingress controller's.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/stalwart/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00

615 lines
35 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"
},
"spreadAcrossNodes": {
"$ref": "#/definitions/spreadAcrossNodes"
},
"podDisruptionBudget": {
"$ref": "#/definitions/podDisruptionBudget"
},
"affinity": {
"$ref": "#/definitions/podSpecObject"
},
"nodeSelector": {
"$ref": "#/definitions/podSpecObject"
},
"tolerations": {
"$ref": "#/definitions/podSpecArray"
},
"topologySpreadConstraints": {
"$ref": "#/definitions/topologySpreadConstraints"
},
"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"
},
"spreadAcrossNodes": {
"$ref": "#/definitions/spreadAcrossNodes"
},
"podDisruptionBudget": {
"$ref": "#/definitions/podDisruptionBudget"
},
"affinity": {
"$ref": "#/definitions/podSpecObject"
},
"nodeSelector": {
"$ref": "#/definitions/podSpecObject"
},
"tolerations": {
"$ref": "#/definitions/podSpecArray"
},
"topologySpreadConstraints": {
"$ref": "#/definitions/topologySpreadConstraints"
},
"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"
},
"spreadAcrossNodes": {
"$ref": "#/definitions/spreadAcrossNodes"
},
"podDisruptionBudget": {
"$ref": "#/definitions/podDisruptionBudget"
},
"affinity": {
"$ref": "#/definitions/podSpecObject"
},
"nodeSelector": {
"$ref": "#/definitions/podSpecObject"
},
"tolerations": {
"$ref": "#/definitions/podSpecArray"
},
"topologySpreadConstraints": {
"$ref": "#/definitions/topologySpreadConstraints"
},
"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. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. 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) - and the HTTPRoute reads it as its hostname as well. 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 routing object - Ingress or HTTPRoute - points at, through that group's own http Service. Shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress. Must be a key of nodes: and must expose http; both are checked in both templates. 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. INERT IN GATEWAY MODE, and for this chart that is the consequential one: this installation's live Ingress carries its CORS configuration here (enable-cors plus allow-origin/methods/headers for the bulwark webmail). See values.yaml, httpRoute block - the Gateway API does have a CORS filter, and the chart still does not render it.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; the HTTP hostname's certificate sits on the Gateway's listener and belongs to the platform. ONE route, not one per node group - it targets ingress.nodeGroup's http Service, with the same two guards the Ingress has. THE MAIL CERTIFICATE STAYS in this mode: it covers STARTTLS on 25 and implicit TLS on 465/993, which pass through no HTTP proxy, so ingress.domain stays required and the Certificate must not be removed as part of a migration. SMTP, IMAP and ManageSieve are not routed here any more than by the Ingress. NOT COVERED IN THIS MODE: the CORS configuration this installation relies on for the bulwark webmail. A CORS filter DOES exist in the pinned gateway-api CRDs (v1.6.3, GEP-1767) but is 'Support: Extended', implementations vary (Cilium accepts it and forwards the preflight upstream anyway), the chart has no CORS values to render it from, and a bare responseHeaderModifier cannot answer a preflight - which is every request here, since the allowed headers include Authorization. Configure CORS at the Gateway and test from a browser, not with curl. See values.yaml, httpRoute block.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the DATABASE, not in any chart value: after bootstrap the WebUI is reached at https://<configured server hostname>/admin, so a second hostname can be reachable without the application agreeing that it is its name.",
"type": "array",
"items": { "type": "string" }
}
}
},
"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"
},
"valkeySubchart": {
"description": "The dependency condition for the valkey subchart. A key of THIS chart, deliberately outside the 'valkey' subtree: everything under 'valkey:' is handed to the subchart as its values, and this repo's valkey chart has additionalProperties: false with no 'enabled' property, so 'valkey.enabled' is rejected by the subchart itself. Helm skips a disabled subchart's schema validation, so that error would only have surfaced for whoever first set it to true.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"valkey": {
"description": "Values passed through to the valkey subchart - this repo's OWN valkey chart, resolved from the published index. Open on purpose and at every level: the subchart owns this schema (it ships its own, and a stricter one than this), and a closed copy here would reject whatever that chart adds in its next release while also duplicating a validation that already happens. This chart reads NONE of these keys; the dependency condition is valkeySubchart.enabled instead. Note the field shape that is easy to get wrong: secrets.authSecretRef points at an item whose single field 'auth-conf' holds the complete line 'requirepass <password>', not a bare password. values.yaml argues the cache-vs-queue choice.",
"type": "object"
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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
}
},
"spreadAcrossNodes": {
"description": "Renders a topologySpreadConstraints entry for THIS group on topologyKey kubernetes.io/hostname, maxSkew 1, whenUnsatisfiable DoNotSchedule, nodeAffinityPolicy Ignore, with a labelSelector the chart fills from its own pod labels - the user never writes it, because a selector that matches no pod is trivially satisfied, distributes nothing and reports nothing. Rendered only while the group's COMPUTED replica count is >= 2, so scaleDown: true removes it as well. DoNotSchedule makes it a promise rather than an effort, and the price is a Pending pod: when two of three workers are gone (while their Node objects still exist), or when a nodeSelector/affinity narrows the eligible nodes below the replica count. nodeAffinityPolicy is beta - on by default from Kubernetes 1.26, GA with the NodeInclusionPolicyInPodTopologySpread gate in 1.33 - and is pruned SILENTLY on anything older, which brings back exactly the co-location it was set against. The replica ceiling (as many per group as there are schedulable workers, because control-plane domains hold the minimum at 0) comes from nodeTaintsPolicy and is NOT a consequence of that field. The whole rule holds only while this chart stays PVC-free - see open measurement 1 on #65.",
"type": "boolean"
},
"podDisruptionBudget": {
"description": "One PodDisruptionBudget per node group, rendered only while that group's COMPUTED replica count is >= 2 (so scaleDown: true renders none). The condition is the point, not tidiness: minAvailable 1 with exactly one replica forbids every voluntary eviction, so kubectl drain blocks forever on a pod nothing may move - and the message names the PDB rather than the replica count. minAvailable rather than maxUnavailable, as in keycloak: the promise is about the service. The selector comes from the same definition as the pod template's labels, because a PDB matching no pod never blocks an eviction while looking like protection.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"minAvailable": { "type": "integer", "minimum": 1 }
}
},
"podSpecObject": {
"description": "Passed verbatim into this group's pod spec (affinity, nodeSelector). Loosely typed on purpose, as with the probes and the security contexts: Kubernetes owns this schema, the chart reads none of the contents, and a stricter copy here would reject valid configurations rather than catch mistakes. Declaring the key is not optional even so, because the level above carries additionalProperties: false. Empty renders no field at all. Mind that narrowing the eligible nodes below a group's replica count while spreadAcrossNodes is on leaves a replica permanently Pending - intended, and loud by design.",
"type": ["object", "null"]
},
"podSpecArray": {
"description": "Passed verbatim into this group's pod spec (tolerations). Loosely typed for the same reason as podSpecObject. Note that tolerating a taint does not raise the replica ceiling: with nodeTaintsPolicy at its default of Ignore an untolerated node already counts as an empty domain.",
"type": ["array", "null"]
},
"topologySpreadConstraints": {
"description": "Additional constraints for this group, APPENDED after the chart's own and really AND-ed with it, so a second key such as topology.kubernetes.io/zone works as expected. Loosely typed like the other pass-throughs. An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule - or omitted, which IS DoNotSchedule - aborts the render naming both this value and spreadAcrossNodes: Kubernetes keys a constraint on that pair, kubectl create rejects the duplicate with a message about a list index instead of a value, and an apply-based path merges the two over patchMergeKey: topologyKey silently, dropping one labelSelector for a green result.",
"type": ["array", "null"]
},
"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"]
}
}
}