Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Selector match (pull_request) Successful in 16s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build stalwart (pull_request) Successful in 8s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 22s
Install-test / Install-test stalwart (pull_request) Successful in 1m44s
Validate Helm Charts / Install-test (pull_request) Successful in 1m44s
The policy route for mta-sts.<domain> belonged to another repository
(jk-cluster-config) while pointing at this chart's own Service. It moves
here as mtaSts.hosts, a list, so several domains - subdomains included -
are maintainable in one place and in whichever routing mode is active.
THE LIST IS THE SWITCH. No mtaSts.enabled: "enabled with an empty list"
would be a state with no meaning that the chart would then have to catch.
The schema rejects the key outright, so the rule is enforced rather than
documented. Default [] renders nothing in either mode - the defaults and
the unchanged CI fixture both produce a byte-identical helm template
against origin/main.
Entry form {host, tlsSecretName, clusterIssuer?}. The two TLS fields are
read ONLY in Ingress mode; in Gateway mode TLS sits on the listener, so
requiring them there would demand a value that does nothing.
THE PATH IS HARDWIRED. RFC 8461 fixes /.well-known/mta-sts.txt, rendered
with pathType Exact (type: Exact in the route). A values key for a
protocol-mandated path can only ever be set wrong, and wrong is invisible
from this side. Exact also matters in itself: a prefix on these hostnames
would publish the mail server's whole management surface under them.
ONE INGRESS PER CLUSTER ISSUER, NOT ONE OBJECT - and the plan's own three
requirements forced that. cert-manager reads the two fields at different
scopes: cluster-issuer is an ANNOTATION (object-scoped, and absent means
nothing is issued for that object at all), while secretName is
tls-entry-scoped and yields one Certificate per entry. So an object cannot
both request a certificate and leave another host's secret alone, which is
what an empty per-entry issuer has to mean. Measured in jk-cluster:
ingress-shim names the Certificate AFTER THE SECRET and owns it from the
Ingress, so a shared secret must sit in exactly one object (hence grouping
the tls list by secret, reusing rss-bridge.tlsGroups' shape) and a secret
may not span two issuer groups (hence the abort). Objects are named by
issuer, not by index: an index is only free where the object holds no
certificate, which is the precondition homeassistant's route naming states
for itself.
Gateway mode renders ONE HTTPRoute with every hostname - nothing differs
per host there - attached to the same Gateway(s) as the WebUI route.
WHAT THE CHART CANNOT PROMISE, AND THEREFORE NAMES: it registers a route,
it does not create a domain. The domains live in Stalwart's data store, so
an entry whose domain is missing there answers 404 at a correctly routed
endpoint with every object green - and MTA-STS failures are silent on the
SENDER's side, which delivers in the clear and logs nothing here. Same for
the _mta-sts.<domain> TXT record and the policy's content. The subdomain
trap is documented too: mta-sts.sub.<domain> is one label below
*.<domain>, so a parent wildcard certificate does not cover it.
Not folded into certificate.extraDnsNames deliberately - that would hang
the MAIL certificate on this list, letting a bad policy entry take STARTTLS
and IMAPS with it.
Minor, additive, empty default: 1.2.0+upv0.16.25 -> 1.3.0+upv0.16.25.
CI: ci/httproute/stalwart.yaml enters all three shapes of the entry form
(with issuer, without, and a subdomain), taking the HTTPRoute render test
from 56 assertions over 21 charts to 58 over 21 - stalwart from 2 to 4.
The Ingress half cannot be covered there, since an overlay's whole job is
to switch the mode off, so ci/stalwart/test-values.yaml carries two
entries and the install-test has a real API server accept them.
Fixes #123
648 lines
39 KiB
JSON
648 lines
39 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" }
|
|
}
|
|
}
|
|
},
|
|
"mtaSts": {
|
|
"description": "MTA-STS policy routes (Gitea #123), rendered in BOTH routing modes. THE LIST IS THE SWITCH - there is no mtaSts.enabled, because 'enabled with an empty list' would be a state with no meaning. Empty (the default) renders nothing and makes no value required. Ingress mode renders ONE Ingress PER DISTINCT clusterIssuer, each with one rule per host and its tls: list grouped by secret, because the cert-manager cluster-issuer is an object-level ANNOTATION while secretName is a per-tls-entry field - so one object cannot both request a certificate and leave another host's secret alone. Gateway mode renders ONE HTTPRoute with all hostnames, since nothing differs per host there. The path is NOT a value: RFC 8461 fixes it at /.well-known/mta-sts.txt with pathType Exact. THE LIMIT: this chart registers a ROUTE, it does not create a DOMAIN - the domains live in Stalwart's data store, so an entry whose domain is missing there yields a 404 at a correctly routed endpoint, and MTA-STS failures are silent on the SENDER's side. Also not done here: the _mta-sts.<domain> TXT record, and the policy's content. See values.yaml, mtaSts block.",
|
|
"type": ["object", "null"],
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"hosts": {
|
|
"description": "One entry per policy hostname. Validated in the template rather than here where JSON Schema cannot reach: a repeated host aborts the render (an error in both modes), and tlsSecretName is required in INGRESS MODE ONLY - in Gateway mode TLS sits on the Gateway's listener, so requiring it there would demand a value that does nothing.",
|
|
"type": "array",
|
|
"items": { "$ref": "#/definitions/mtaStsHost" }
|
|
}
|
|
}
|
|
},
|
|
"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"]
|
|
},
|
|
"mtaStsHost": {
|
|
"description": "One MTA-STS policy hostname. Closed against unknown keys: the three fields are the whole surface, and the path is not among them because RFC 8461 fixes it. 'host' is required here AND in the template - the schema catches the missing key, the template catches a null value and a repeated host, which the schema cannot see across entries.",
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"required": ["host"],
|
|
"properties": {
|
|
"host": {
|
|
"description": "The policy hostname, i.e. mta-sts.<domain>. Mind that mta-sts.sub.domain.tld is one label DEEPER than *.domain.tld, so a wildcard certificate for the parent does not cover it (RFC 6125) - which is what tlsSecretName per entry is for, and in Gateway mode what the Gateway's listener has to account for. The symptom of getting it wrong is a TLS error at the SENDING MTA, logged nowhere in this cluster.",
|
|
"type": "string",
|
|
"minLength": 1
|
|
},
|
|
"tlsSecretName": {
|
|
"description": "The Secret the certificate for this host lives in. REQUIRED IN INGRESS MODE, enforced by required() in the template rather than here, because in Gateway mode TLS sits on the Gateway's listener and the field is inert - a schema 'required' would demand it in a mode where it does nothing. NO DEFAULT on purpose: the plausible one, this release's own tls-<release>-<chart>-ingress, covers ingress.domain and not this host. Hosts sharing one secret are collected into a single tls: entry, so one certificate with several SANs is requested once instead of once per host.",
|
|
"type": ["string", "null"]
|
|
},
|
|
"clusterIssuer": {
|
|
"description": "Optional, and EMPTY MEANS NO ANNOTATION: the certificate for this host comes from elsewhere and cert-manager is not asked for it - the same deliberate omission as the qBittorrent object in freesailarr and two of the four realm ingresses in Gitea #122. Entries that share a tlsSecretName must agree on this value and the render aborts naming both, because cert-manager's ingress-shim names the Certificate after the secret and owns it from the Ingress, while the issuer itself is an object-level annotation. Inert in Gateway mode.",
|
|
"type": ["string", "null"]
|
|
}
|
|
}
|
|
},
|
|
"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"]
|
|
}
|
|
}
|
|
}
|