A two-replica node group was redundancy on paper: nothing kept both pods off one node, and no PodDisruptionBudget kept a drain from taking both at once. Both objects are now rendered per node group, gated on that group's COMPUTED replica count being >= 2. The distribution is a promise, not an effort: maxSkew 1 on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only this group's own pods, so an "mx" and a "full" pod may share a node. The selector comes from the chart (stalwart.groupLabels), never from the operator - a spread constraint whose selector matches no pod is trivially satisfied, distributes nothing and reports nothing. nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing detail. Its API default is Honor, under which a narrowing nodeSelector shrinks the set of counted domains until both replicas fit into one: skew 0, both pods co-located, no Pending, no event, and a selector that is correct - so no check can see it. With Ignore the same narrowing gives skew 2 and the second pod is Pending. A render abort was the alternative and would also have forbidden a nodeSelector spanning five nodes, which is legal. A user entry colliding with the chart's own on (topologyKey, whenUnsatisfiable) aborts the render naming both values, because the API rejects the duplicate with a message about a list index and an apply-based path merges the two over patchMergeKey: topologyKey silently, dropping one selector. Minor rather than major, proved with a diff rather than argued: with the shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml, helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both diffs empty. New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds every rendered PodDisruptionBudget selector and every topologySpreadConstraints labelSelector against the labels of the pod templates in the same render. Helm itself is the YAML parser (ci/yaml2json), because the runners have neither pyyaml nor yq and a hand-written parser could read nothing and report nothing. podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors are reported as not checked rather than counted as passing. Two anti-vacuum guards: a chart with an overlay under ci/selector-match/ that renders no selector fails, and a run that checks no selector at all fails. Fixes #96
592 lines
31 KiB
JSON
592 lines
31 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. 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"
|
|
},
|
|
"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": {
|
|
"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"]
|
|
}
|
|
}
|
|
}
|