8 Commits
Author SHA1 Message Date
jklein 71b757bf9f docs(README): the MTA-STS delta on top of both landed rounds, measured at 64/22
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 24s
Install-test / Install-test stalwart (pull_request) Successful in 1m36s
Validate Helm Charts / Install-test (pull_request) Successful in 1m36s
Second rebase of this branch, and the last one the parallel rounds force.
Both have now merged - rss-bridge plus its documentation corrections, and
the realm hostnames of #122 - so line 184 carries the chain
56/21 -> 58/22 -> 62/22 with the delta convention #124 introduced.

That chain is kept WORD FOR WORD and this round's delta is appended to it;
the only change to the line is an append, verified line by line against
origin/main rather than asserted.

THE NUMBER IS READ, NOT COMPUTED, and that distinction is this paragraph's
own rule. 62 + 2 = 64 is arithmetic over a branch that had a different
substructure when its 2 were measured; the figure committed here comes from
ci/run-httproute-match-test.sh on THIS branch, rebased onto the main that
already holds both other rounds: 64 assertions over 22 charts, 0 failed,
with stalwart contributing 4. It is therefore the first figure in that
sentence that is a genuine running total rather than a delta - the first run
that contains all three rounds, which is exactly where the convention says
to read it.

The earlier 58/21 and 60/22 in this branch's history were each correct for
the substructure they were measured on and are both superseded. The commit
that set 60/22 was dropped in this rebase rather than carried, since its
only content was a number that no longer holds.
2026-10-13 10:42:18 +00:00
jklein 771c26500c fix(stalwart): let the mode conflict, not the MTA-STS entry, name the defect
With BOTH routing modes on and an mtaSts entry missing its tlsSecretName,
the render reported

  mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName, and in
  Ingress mode every entry needs one

which is true and is the wrong diagnosis: the values file's defect is that
ingress.enabled and httpRoute.enabled are both true, and "in Ingress mode"
is a claim about a configuration that is not coherent at all. Whoever met
it would add a tlsSecretName, re-run, and reach the real message one round
later - or conclude the MTA-STS feature was the problem.

Measured on this chart, not reasoned about. And it is the second occurrence
of a defect the parallel keycloak round of Gitea #122 found on its own
realm list, independently and in the same round, which is what makes it a
rule rather than one chart's story: a specific validation must not be
reachable in a state that a structural validation already rejects.

Ordering is not the fix and could not be - Helm renders templates in an
order a chart cannot pin, so this template may not assume the httproute
template's abort runs first. Scope is: the Ingress-mode MTA-STS block is
now gated on ingress.enabled AND NOT httpRoute.enabled, so in the
incoherent combination it validates nothing and the only fail left is the
one that names the real problem.

The per-entry guard is NOT dead by this - verified reachable with
ingress.enabled alone. Defaults, the unchanged CI fixture and the overlay
all still render byte-identically against origin/main, and the HTTPRoute
render test still holds 58 assertions over 21 charts.
2026-10-13 10:41:10 +00:00
jklein b4c9823e01 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
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
2026-10-13 10:41:10 +00:00
jklein 649417b428 Merge pull request 'feat(keycloak): one hostname per realm, in both routing modes' (#125) from feat/keycloak-realm-hosts into main
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 11s
Publish Keycloak Helm Chart / Publish Keycloak Helm Chart (push) Successful in 11s
SonarQube Analysis / SonarQube Analysis (push) Successful in 47s
2026-10-13 10:38:09 +00:00
jklein 5cdb2a6227 Merge origin/main into feat/keycloak-realm-hosts, and settle README line 184
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Selector match (pull_request) Successful in 21s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build keycloak (pull_request) Successful in 8s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 26s
Install-test / Install-test keycloak (pull_request) Successful in 3m43s
Validate Helm Charts / Install-test (pull_request) Successful in 3m43s
The rss-bridge Gateway-API round landed while this branch was open, and the
two rounds shared exactly one line: the sentence in README.md that carries
the whole history of the httproute-match assertion count.

Merged rather than rebased on purpose: the branch is already pushed and its
history is not rewritten here, so nothing of the other round's text can be
lost to a force push.

Their half is kept verbatim, including the delta rule they added - that
58/22 and 60/21 were measured on branches which did not contain each other,
so the delta is what either number can be trusted for, and the running total
belongs to the first branch that holds both.

This branch IS that first branch, so the open question is now answered with
a measurement instead of an addition: 62 assertions over 22 charts, 0 failed,
read off ci/run-httproute-match-test.sh on this merge. keycloak contributes
8 of them, plus the two "not checked" lines for its realm routes'
backend-less redirect rules.
2026-10-13 10:29:55 +00:00
jklein f530f58b1d docs(rss-bridge): #51 was closed, and the cause was never identified (2.1.1)
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 8s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 23s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m6s
Validate Helm Charts / Install-test (pull_request) Successful in 1m6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish RSS-Bridge Helm Chart / Publish RSS-Bridge Helm Chart (push) Successful in 12s
SonarQube Analysis / SonarQube Analysis (push) Successful in 45s
PR #124 merged at 12:25 and the maintainer closed #51 one second later, on
the strength of that round's green install-test. Three statements that
merged with #124 are stale as a result, and they are stale in the
direction that matters - they describe a failing test and an open issue:

  - rss-bridge/templates/rss-bridge.httproute.yaml and values.yaml said
    the install-test "fails on the Gitea runner (#51)", in the present
    tense, and values.yaml added "and that test is still red".
  - ci/httproute/rss-bridge.yaml said "#51 is not closed on the strength
    of a colour", which was true when written and is now the opposite of
    what happened.

Replaced with what is actually known, and the emphasis moved to the part
that outlives the colour: the test went green on a render byte-identical
to main, which rules the chart out and leaves the runner - and THE CAUSE
WAS NEVER IDENTIFIED. The diagnostic programme in #51 (docker info for
userns and the dind configuration, the same image in a local k3d without
remapping) was not executed. So a recurrence is a fresh finding rather
than a known quirk, and the overlay comment now says so where whoever
sees the red will be reading.

Also carries the wording change that was already on this branch when
#124 merged: the reproduced green is phrased as "every run of that branch
since" rather than pinned to a count, because each commit recording the
count invalidated it - a third green arrived with the push after "two".

Patch bump, per the rule #40 was argued on: a comment in a chart
directory is still a change to that chart, so it is a patch plus a
publish. No template output changes - the render for the fixture values
is byte-identical to the pre-#124 main, which the tests re-confirm.
2026-10-13 10:28:09 +00:00
jklein a411ec3c90 docs(rss-bridge): phrase the reproduced green without a run count
The count was pinned at "two consecutive runs" and a third green arrived
with the next push, which is a regress: every commit that records the
number invalidates it. Phrased as "every run of that branch since"
instead, which is stable, still establishes that it is not a fluke, and
cannot go stale in main.

The caution it carries is unchanged and is the one that matters: it is a
colour, not a cause. Nobody has identified what changed on the runner, and
#51's diagnostic programme is still unexecuted.
2026-10-13 10:25:55 +00:00
jklein 7a2eb29e9e feat(keycloak): one hostname per realm, in both routing modes
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build keycloak (pull_request) Successful in 8s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 24s
Validate Helm Charts / Install-test (pull_request) Successful in 3m29s
Install-test / Install-test keycloak (pull_request) Successful in 3m29s
keycloak.realmHosts gives a realm a hostname of its own: this release's
Service published under a further name, with that name's bare / sent to
/realms/<realm>/account/. It renders in BOTH modes - one Ingress per entry
carrying nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry
whose Exact match on / carries a RequestRedirect filter - because a routing value in
this repository is mode-independent by design - ingress.domain is read in
Gateway mode too - so a feature rendering in one mode only would silently
disappear when an installation switched.

Default []: nothing renders, and the render is byte-identical to 1.2.0 for
the fixture's values in both modes. Minor, additive, no value becomes
required.

ONE OBJECT PER ENTRY, not one with several hostnames. spec.hostnames applies
to the whole object and a rule's matches are path-based, so inside a shared
route every entry would match Exact: / and the realm could not be told
apart. Names therefore carry the LIST INDEX, as homeassistant's per-host
routes do and for the same reason - a realm name is not a dependable
object-name fragment. The consequence is written down rather than left to be
discovered: reordering the list renames and recreates the objects.

THE WORK IS IN THE INGRESS HALF, NOT THE ROUTE. Two convention rules do not
survive these objects, and both deviations are required rather than
tolerated: tlsSecretName is a per-entry value with NO default, because these
hostnames already have certificates and several entries typically share one,
so a derived name would ask cert-manager for a second certificate for a name
a wildcard already covers; and the cert-manager annotation is OPTIONAL per
entry, because cert-manager keys a Certificate by the secret it writes and
only one object per secret may request it - the same deliberate omission the
qBittorrent object in freesailarr makes. Annotations are per entry too: of
the four hostnames this was written for, three carry the proxy-buffer pair
and one does not, which a chart-global block could not express.

statusCode: 302 is written out at the field's own default. 301 and 302 are
both Support: Core, so the portability trap of #119 - an implementation
declining an Extended code sets Accepted: False on the WHOLE route - does
not apply here; ingress-nginx emits 302 for app-root, and a value that is
only accidentally right documents nothing.

Five render aborts, each against a failure that reports nothing: an entry
without a host (ingress-nginx reads an empty host as a catch-all for the
whole controller), without a realm (/realms//account/, a 404 behind a
healthy hostname and certificate), without a tlsSecretName in Ingress mode
(the controller's default certificate, a browser-only failure), a repeated
host or one the release already publishes (webhook rejection in Ingress
mode, two merged routes decided by object age in Gateway mode), and a filled
list with both modes off (no object at all, and nothing in the release that
reveals the list was ignored). The entry-shape checks sit in a shared helper
so they exist in both modes, and the Ingress template skips them while
httpRoute.enabled is also true so that the specific message cannot hide the
mutual-exclusion one.

CI: the two entries live in ci/keycloak/test-values.yaml, which
ci/httproute/keycloak.yaml is applied on top of, so one definition covers
both modes - two Ingress objects in the install-test, two HTTPRoutes in the
render test. They cover both Ingress forms, with and without a clusterIssuer,
and the render test holds their inertness in Gateway mode. The httproute
match test goes from 56 assertions over 21 charts to 60, keycloak's share
from 4 to 8 plus two "not checked" lines for the backend-less redirect rules.
The #118 prefix/redirect pairing correctly does not fire: it is scoped to
rules that strip a prefix, and a realm route strips nothing.

NOT IN THIS CHANGE: the migration. Nothing in jk-cluster-config is touched.
With the default [] no running system changes, so the order a takeover has to
run in is documented instead - remove the objects in the owning repository
first, then install with the list filled, because the reverse or both at once
is rejected by ingress-nginx's admission webhook and leaves a Fleet bundle in
ErrApplied until forceSyncGeneration is raised. Whether anything there
depends on those objects BY NAME is unchecked and named as unchecked.

Fixes #122
2026-10-13 10:13:08 +00:00
23 changed files with 1343 additions and 33 deletions

No files matched your search

+4 -3
View File
@@ -23,7 +23,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
| `garage` | Garage (S3-compatible object store) | **One** node, `replication_factor = 1` — a deliberate trade with its price named (Gitea #81): the volume layer underneath already replicates and encrypts, so Garage replication on top would be nine times the raw data across two layers that know nothing about each other. What that costs is that the factor applies to the **metadata** too, where a corrupted database is upstream-documented as "generally not recoverable" and a replicated volume replicates the corruption. Hence three settings that are mandatory rather than optional: `metadata_fsync = true` (**off** in Garage's defaults), the automatic metadata snapshots, and those snapshots on a **second PVC** — a file-system snapshot of a live metadata database is not a restore point, so the volume backup covers the blocks and not the index. The first PVC carries `metadata_dir` **and** `data_dir` as two subdirectories, because volume snapshots are atomic per volume: that is what keeps index and blocks consistent with each other. Two further properties shape the whole chart: the image is **`FROM scratch`** and contains exactly one file, `/garage` — no shell, so **exec probes are impossible here** and an init container would need a second image — and the RPC secret has to arrive as an **environment variable**, because `rpc_secret_file` rejects any file with a group bit set while the kubelet sets one (the reasoning sits at the value). Started with `garage server --single-node`, which creates the cluster layout in-process before any listener opens and is idempotent on the layout version, and with `--default-access-key`/`--default-bucket`, which **import** key and bucket from the 1Password item — so the vault is the source of truth instead of a copy of what the server generated. Readiness is the admin API's `/health`, liveness and startup deliberately are **not**: `/health` answers 503 until a layout exists, so a node waiting for one would be restarted forever |
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses — plus one routing object per `keycloak.realmHosts` entry (Gitea #122), empty by default, which gives a realm a hostname of its own by sending that hostname's bare `/` to `/realms/<realm>/account/`: `app-root` in Ingress mode, an `Exact` match with a `RequestRedirect` filter in Gateway mode. Those entries carry a **required** per-entry TLS secret name and an **optional** per-entry issuer, the documented deviation in §4, because the hostnames they publish already have certificates and often share one. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java<N>` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: <release>-java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart |
@@ -181,9 +181,9 @@ Three things to know before touching it:
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing more: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart, and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; both verdicts are correct at once.
- **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/<chart>.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered.
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. Group 3 took it to `56` assertions over `21` charts, from six charts of which three contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total is whatever the first run on `main` prints once both have landed, and it should be read there rather than added up by hand. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path).
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. Group 3 took it to `56` assertions over `21` charts, from six charts of which three contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total was therefore read off the first branch that contained both — the realm-hostname branch rebased onto this one — and it is **`62` assertions over `22` charts**, `0` failed. `keycloak` contributes `8` of them: one `parentRefs` and one `backendRefs` assertion for each of its four routes, the two realm routes adding one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on those two: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path). **The MTA-STS routes of Gitea #123 add `2`**, and they are the entry that shows what this figure means once the sweep is over: `stalwart` goes from `2` assertions to `4` — one `parentRefs` and one `backendRefs` assertion for its new MTA-STS route — and **the chart count does not move at all**. Every earlier entry in this history grew because a chart *joined* the list; this is the first that grew because a chart already in it gained an **object**, which is the ordinary shape of the next one too. Following the same delta rule, and read off the first run that contains all three rounds — this branch, rebased onto a `main` that already held `rss-bridge` and the realm hostnames — the figure is **`64` assertions over `22` charts**, `0` failed. The delta counts only because the overlay was extended to enter the new shape: `ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one host on a **subdomain**. An overlay left alone would have added a second route this test never rendered — the anti-vacuum rule biting on a *second* object inside a chart that already passes, which no per-chart "has an overlay" check can see. **The Ingress half of that feature is not covered here at all**, because an overlay's one job is to switch Gateway mode on; it sits in `ci/stalwart/test-values.yaml`, where the install-test has a real API server accept it.
**And in the same PR that chart's install-test went green, which is the event Gitea #51 was waiting for.** It had failed on the Gitea runner for months (uid 33 unable to read `/app/index.php`, startup probe 403 through its whole budget), and the chart was deliberately kept *out* of `ci/excluded-charts.txt` precisely so that the day it passed would be noticed rather than silenced. It passed: k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no `Permission denied` anywhere in the job. **The chart cannot be the reason, and that is measured rather than argued** — the rendered output for the fixture values is byte-identical to `origin/main`, so the pod spec that installed is exactly the one `main` produces. The implicated layer is the runner, which is where #51 had already put the burden of proof. Two cautions belong with the result: **nobody has identified what changed on the runner**, and a green test does not explain itself — it reproduced across two consecutive runs on two different commits of that PR, which is two data points rather than one, but still no cause; and the install covers **Ingress** mode, since that is what the fixture installs — so this chart's Gateway mode stays render-only, exactly like its 21 siblings rather than worse than them.
**And in the same PR that chart's install-test went green, which is the event Gitea #51 was waiting for.** It had failed on the Gitea runner for months (uid 33 unable to read `/app/index.php`, startup probe 403 through its whole budget), and the chart was deliberately kept *out* of `ci/excluded-charts.txt` precisely so that the day it passed would be noticed rather than silenced. It passed: k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no `Permission denied` anywhere in the job. **The chart cannot be the reason, and that is measured rather than argued** — the rendered output for the fixture values is byte-identical to `origin/main`, so the pod spec that installed is exactly the one `main` produces. The implicated layer is the runner, which is where #51 had already put the burden of proof. Two cautions belong with the result: **nobody has identified what changed on the runner**, and a green test does not explain itself — it has reproduced on every run of that branch since, across several commits, so it is not a fluke - but that is still a colour and not a cause; and the install covers **Ingress** mode, since that is what the fixture installs — so this chart's Gateway mode stays render-only, exactly like its 21 siblings rather than worse than them.
The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
@@ -390,6 +390,7 @@ These guidelines are **binding for every current and future chart** in this repo
- `ingress.className` selects the ingress class and defaults to `nginx`.
- `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets.
- The TLS secret is named `tls-<release>-<chart>-ingress`.
- **A set of objects publishing ONE backend under hostnames the chart does not own is the third admissible shape, and it buys its secret name and its issuer per entry (Gitea #122).** Where a chart's app serves several tenants from one deployment and each wants a hostname of its own — `keycloak`'s realms are the case this was written for — the hostnames come as a values list and the chart renders one object per entry. Two convention rules do not survive that, and both deviations are **required**, not merely tolerated: the TLS secret name is a per-entry value with **no** default, because these hostnames already have certificates and several entries typically share one (a wildcard for a zone), so a derived `tls-<release>-<chart>-...` name per entry would ask cert-manager for a second certificate for a name that is already covered — one ACME order per hostname, for nothing; and the cert-manager annotation becomes **optional per entry**, because cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry it. That is the same deliberate omission the qBittorrent object in `freesailarr` makes, for the same reason, and it is why the secret name is required rather than defaulted: a chart that invents the name cannot also leave the issuing to somebody else. Annotations are per entry too, since the set is not uniform across the hostnames. Object names carry the **list index**, for the reason spelled out in the homeassistant bullet below — a hostname is not a dependable object-name fragment, so reordering the list renames and recreates the objects — and the entries render in **both** routing modes: a routing value in this repository is mode-independent by design, so a feature rendering in one mode only would silently disappear when an installation switched. That is also why the per-entry `parentRefs` exists, as the counterpart of the per-entry class.
- **Documented exception — homeassistant, and it extends to the Gateway mode**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). Because it has **no `ingress.domain`**, it has **no `httpRoute.hostnames`** either — the second chart without that key after `freesailarr`, and the schema rejects it outright so the absence is enforced rather than merely intended. The hostnames and the paths come from `ingress.hosts`, which is therefore read in **both** modes and is not an Ingress-only value, and the chart renders **one `HTTPRoute` per entry** of that list with one rule per path of the entry. One object carrying several hostnames would be wrong rather than merely different: an `HTTPRoute`'s rules apply to *every* hostname of the object, and per-host paths are this chart's whole point. `ingress.className`, `ingress.tls` and `ingress.annotations` are not read in Gateway mode. Route names carry the entry's **index**, not its hostname (a hostname is not a dependable object-name fragment), so reordering `ingress.hosts` renames routes and therefore recreates rather than edits them — harmless, since a route holds no state and no certificate, but worth knowing before it looks like a bug. Its `pathType` default is the awkward one; see the `ImplementationSpecific` rule in the Gateway-API bullets below.
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The rules for the shared shape: the domain is a single value (`ingress.host`), each app's path is its own value defaulting to `/<app>`, the paths render in one Ingress object `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
- **The Gateway API is the second routing mode, and it is implementation-neutral by construction (Gitea #114).** A chart offers it through one values block, off by default:
+20 -2
View File
@@ -30,11 +30,29 @@
# parentRefs fall back to httpRoute.parentRefs below - the empty-admin
# branch, which is the one an installation hits unless it wants an
# internal Gateway.
# - both routes' backendRef is checked against the serving Service of the
# same render. Both point at the SAME Service, by PORT NUMBER
# - every route's backendRef is checked against the serving Service of the
# same render. They all point at the SAME Service, by PORT NUMBER
# (keycloak.ports.http) rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber - so this overlay is what holds
# that number against the Service's.
# - THE TWO REALM ROUTES (Gitea #122), which come from keycloak.realmHosts in
# the fixture rather than from this file. Nothing is repeated here on
# purpose: the fixture installs them as Ingress objects and this overlay
# turns the same two entries into HTTPRoutes, so one definition covers both
# modes and the two halves cannot drift into describing different hostnames.
# What the routes add to the two above is a SECOND RULE SHAPE - an Exact
# match on / carrying a RequestRedirect filter and no backendRefs at all.
# That rule is reported as "not checked" by this test, correctly and by
# design: a filter-only rule has no backend to resolve. The assertion it
# does earn is on its sibling rule, the PathPrefix one that points at the
# Service. The prefix/redirect pairing check of #118 does NOT fire for these
# routes and must not: it is scoped to rules that STRIP a prefix with a
# ReplacePrefixMatch rewrite, and these strip nothing - they redirect the
# bare root of a hostname that serves one realm.
# The entries also cover the two Ingress-mode forms (one with a
# clusterIssuer, one without) and what this test holds about them is their
# INERTNESS: neither tlsSecretName nor clusterIssuer nor the per-entry
# annotation may appear anywhere in a rendered route.
#
# NOT covered here, and it cannot be: that either hostname answers. No Gateway
# controller runs in CI, no route reaches "Accepted: True", and the
+7 -2
View File
@@ -28,8 +28,13 @@
# in the job. Since that PR's rendered defaults are byte-identical to
# origin/main, the chart is ruled out as the cause and the runner is the
# implicated layer - which is where #51 had already placed it. It reproduced
# over two consecutive runs, but nobody has identified what changed there, so
# #51 is not closed on the strength of a colour.
# on every run of that branch since, so it is not a fluke, and #51 was closed
# on that round. What was NEVER identified is the cause: nobody established
# what changed on the runner, and the diagnostic programme in that issue
# (docker info for userns and the dind configuration, the same image in a
# local k3d without remapping) was not executed. So if this chart's
# install-test goes red again, that is a fresh finding and not a known quirk -
# and this comment is the context it should be read against.
#
# Either way this overlay stays load-bearing: the install-test installs the
# INGRESS mode (that is what the fixture sets), so the Gateway mode of this
+36
View File
@@ -51,6 +51,26 @@
# go through the route; the mail Certificate that serves them keeps being
# rendered in this mode, which is this chart's distinguishing feature in
# the sweep, and no render test looks at whether it was left alone.
# - THE MTA-STS ROUTE (Gitea #123), which is the second HTTPRoute this chart
# renders. Its backendRef is held against the same per-group http Service
# by the same port number, so the one place information travels from the
# Service object into a route template is checked twice rather than once.
# What the list below is shaped for is the three cases the entry form has:
# an entry WITH a clusterIssuer, an entry WITHOUT one, and a host on a
# SUBDOMAIN - the last because mta-sts.sub.<domain> is one label deeper
# than *.<domain> and a parent wildcard does not cover it.
#
# Both TLS fields are INERT IN THIS MODE: TLS sits on the Gateway's
# listener and this chart renders no certificate for an HTTP hostname
# here. They are set anyway, and that is the assertion - the render has to
# ACCEPT them in Gateway mode without requiring them, because the mirror
# image (tlsSecretName required in Ingress mode) is what makes them
# mode-specific rather than optional-everywhere.
#
# The Ingress half of this feature is NOT exercised here - this overlay's
# whole job is to switch the mode off. It is exercised by
# ci/stalwart/test-values.yaml instead, which installs in Ingress mode and
# therefore has the MTA-STS Ingress objects accepted by a real API server.
ingress:
enabled: false
httpRoute:
@@ -59,3 +79,19 @@ httpRoute:
- name: public
namespace: gateway
sectionName: https
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# One label deeper: its own secret, because the parent's wildcard would not
# reach it. In this mode that is the Gateway listener's problem, which is
# exactly why the entry is here and why the values are inert.
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# No clusterIssuer: "the certificate comes from somewhere else". Nothing in
# this mode reads it either way; the point is that the render accepts the
# entry without it.
- host: mta-sts.other.ci.example
tlsSecretName: tls-some-existing-wildcard-ingress
+37
View File
@@ -203,6 +203,43 @@ keycloak:
mountPath: /opt/keycloak/themes/ci-theme
readOnly: true
# TWO REALM HOSTNAMES (Gitea #122), and they are set HERE rather than in the
# Gateway overlay on purpose: ci/httproute/keycloak.yaml is applied on top of
# this file, so one definition covers BOTH modes - two Ingress objects in this
# install, two HTTPRoutes in the render-only test, from the same two entries.
# Defining them in the overlay instead would leave the Ingress half of the
# feature with no coverage at all, and it is the half that carries the
# required value and the convention deviation.
#
# WHAT THIS INSTALL ADDS TO THE RENDER TEST, which is the part a cluster-less
# check cannot reach: the API server accepts both objects, including the
# Service port named BY NAME ("http") - a typo there is invisible until
# somebody opens the URL - and the second entry's cert-manager annotation does
# not make the apply fail in a cluster without cert-manager. No ingress
# controller answers in CI, so neither app-root nor the proxy buffers are
# exercised; that is the same gap the two objects above have.
#
# THE TWO ENTRIES ARE THE TWO FORMS, deliberately:
# [0] NO clusterIssuer - the normal case, where the certificate comes from
# elsewhere (a wildcard, another release) and this object only consumes
# the secret. It also carries a per-entry annotation, which is what
# makes the per-entry (rather than chart-global) annotations block a
# tested shape rather than an intended one.
# [1] WITH clusterIssuer - the case where the certificate is issued for this
# entry's own secret.
# Both are inert in Gateway mode, and the render test is what holds that: the
# routes it renders from these entries must show no trace of either field.
realmHosts:
- host: realm-one.ci.example.com
realm: ci-realm-one
tlsSecretName: tls-ci-realm-shared-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
- host: realm-two.ci.example.com
realm: ci-realm-two
tlsSecretName: tls-ci-realm-two-ingress
clusterIssuer: ci-selfsigned
ingress:
# Left ON although no ingress controller answers in CI: the point is that
# both objects are accepted and that the Service port they name BY NAME
+31
View File
@@ -83,6 +83,37 @@ ingress:
clusterIssuer: ci-selfsigned
nodeGroup: full
# MTA-STS policy routes (Gitea #123). Set HERE and not only in the render
# overlay, because this is the ONLY stage that puts the INGRESS half of the
# feature in front of a real API server: ci/httproute/stalwart.yaml switches
# the chart into Gateway mode, so it never renders these objects at all.
#
# WHAT THIS ADDS OVER THE RENDER TEST: that two Ingress objects whose names
# differ only by the issuer suffix are both accepted, that a `pathType: Exact`
# on `/.well-known/mta-sts.txt` is valid, and that a tls: list grouping several
# hosts under one secret is accepted as written. None of that is a template
# question any more once the API server has taken it.
#
# THE TWO ENTRIES ARE TWO OBJECTS, and that is the point of the pair: the first
# carries a clusterIssuer and gets the cert-manager annotation, the second has
# none and gets no annotation - which cannot be one object, because the
# annotation is object-scoped. The second is also a SUBDOMAIN, one label deeper
# than the first, carrying the case a parent wildcard certificate would not
# cover.
#
# NOTHING ISSUES EITHER CERTIFICATE here: cert-manager is not installed in the
# k3d cluster, so the annotation is inert and the named Secrets never exist.
# That is the same deliberate trade as certificate: below - the objects are
# asserted, the issuance is not, and no Secret is needed for the install to
# reach Ready because nothing mounts these.
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
certificate:
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
# which is the interesting case: the volume is mounted optional, so the pods
+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# procedure lives in NOTES.txt and on the wiki page instead of in a
# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
# merging an app-version PR that changes the minor.
version: 1.2.0+up26.8.0
version: 1.3.0+up26.8.0
appVersion: "26.8.0"
maintainers:
+59 -1
View File
@@ -18,6 +18,7 @@ see of it.
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (.Values.ingress.admin | default dict).domain -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.settings" .)).realmHosts | default list -}}
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
@@ -45,16 +46,40 @@ ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true,
/health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
WITHOUT authentication. It has a ClusterIP Service
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
{{ if $realmHosts }}
REALM HOSTNAMES - {{ len $realmHosts }} further name(s), each landing in ONE realm
The object names carry the LIST INDEX and not the realm, so this is the only
place that says which is which. Reordering keycloak.realmHosts therefore
renames these objects and recreates them.
{{ range $i, $entry := $realmHosts }}
https://{{ $entry.host }}
realm {{ $entry.realm }} (bare / -> /realms/{{ $entry.realm }}/account/)
object {{ $.Release.Name }}-{{ $.Chart.Name }}-realm-{{ if $httpRoute.enabled }}httproute{{ else }}ingress{{ end }}{{ if $i }}-{{ $i }}{{ end }}
{{- if not $httpRoute.enabled }}
cert {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested by THIS object ({{ $entry.clusterIssuer }}){{ else }}, NOT requested by this object - it must already exist or be issued elsewhere{{ end }}
{{- end }}
{{ end }}
{{- /*
No "and nothing publishes them" branch here, deliberately: a filled
keycloak.realmHosts with both modes off ABORTS the render in
keycloak.ingress.yaml, so this block can only ever be reached with a mode on.
A branch for an impossible state reads like a state somebody has seen.
*/ -}}
{{- end }}
{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders {{ if $adminHostname }}TWO HTTPRoutes{{ else }}ONE HTTPRoute{{ end }} and NOTHING else of the routing path -
This release renders {{ add (ternary 2 1 $adminHostname) (len $realmHosts) }} HTTPRoute(s) and NOTHING else of the routing path -
no Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
{{- if $adminHostname }}
{{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute {{ join ", " $adminHostnames }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $entry.host }} (realm {{ $entry.realm }})
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
@@ -69,6 +94,11 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
{{- range ($httpRouteAdmin.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (admin)
{{- end }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
{{- range ($entry.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (realm {{ $entry.realm }})
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
@@ -84,6 +114,12 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
for this release.
{{- if $realmHosts }}
The realm hostnames above are part of that: their tlsSecretName values
are not read in this mode either. Where several of them share a DNS zone,
ONE wildcard listener for that zone replaces one certificate per name -
which is the one thing this mode makes cheaper here rather than dearer.
{{- end }}
AND THE ONE YOU WILL NOT NOTICE UNTIL A LOGIN FAILS: THE PROXY BUFFERS.
@@ -109,6 +145,28 @@ GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
the Gateway API has no annotation channel for controller behaviour. An IP
allow-list for the admin host is the other thing that has to move - and read
the first trap below before relying on it in either mode.
{{ if $realmHosts }}
AND THE SAME LOSS ONCE MORE, ON THE REALM HOSTNAMES - where it is likelier to
be met rather than merely possible.
Each entry of keycloak.realmHosts carries its own annotations block in Ingress
mode, and in this mode every one of them is gone: {{ len $realmHosts }} hostname(s) here, none
of which can configure a proxy buffer through this chart any more. What the
entries CAN still express is the routing - the redirect into the realm is a
rule of the route and survives the mode change intact.
Why these hostnames are the awkward ones: they exist to put somebody in an
ACCOUNT CONSOLE. That is a logged-in session from the first request after the
redirect, so the large Authorization and Set-Cookie headers are the normal
traffic on these names rather than an edge case the way they are on a login
host that mostly serves anonymous requests. A buffer sized for the default
therefore fails here FIRST, and it fails as a 502 from the Gateway on a
hostname whose Ingress never had a chart value to point at - so there is
nothing to grep for and nothing to un-set.
Set the buffer equivalent for these hostnames too, in the Gateway
implementation's own object, and set it before the names resolve there.
{{- end }}
THE ADMIN HOSTNAME'S "INTERNAL CLASS" MOVES TO THE PARENT. What
ingress.admin.className does in the other mode, httpRoute.admin.parentRefs
+100 -1
View File
@@ -184,7 +184,8 @@ Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
"features" (list)
"cache" (dict "clusterName" nil)
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1)
"realmHosts" (list) -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}}
{{- end -}}
@@ -342,6 +343,104 @@ true
{{- end -}}
{{- end -}}
{{/*
THE REALM HOSTNAMES (Gitea #122) - validated here once, rendered twice.
WHAT THEY ARE. Keycloak serves every realm from one deployment, and a realm's
account console lives at /realms/<realm>/account/. A realm therefore gets a
hostname of its own by publishing THIS release's Service under that name and
sending the bare / to that path. The list is keycloak.realmHosts, it defaults
to empty, and an empty list renders nothing in either mode - the feature is
additive and the default render is byte-identical without it.
WHY THE VALIDATION IS SHARED AND NOT PER TEMPLATE. The same list feeds the
Ingress and the HTTPRoute template, so a check written in one of them is a check
that does not exist in the other mode - and the failure these checks are about
is a hostname that resolves, answers, and serves the wrong thing or nothing at
all. None of it is loud: an entry without a realm would render an Ingress whose
app-root annotation reads "/realms//account/", an entry without a host would
render an object with an empty host that ingress-nginx treats as a CATCH-ALL for
the whole controller, and an entry without a tlsSecretName would render a TLS
block with no secret, which serves the controller's default certificate and
fails in the browser only.
THE MODE DECIDES WHICH FIELDS ARE LOAD-BEARING, which is why it is a parameter
rather than read from the values here: tlsSecretName and annotations are Ingress
fields with no counterpart in the Gateway API (see keycloak.httproute.yaml), and
parentRefs is the other way round. A field demanded in a mode that ignores it is
the defect guideline 12 exists to remove, so each is required only where it is
read.
Input: dict "root" <the root context> "mode" "ingress"|"gateway"
Output: YAML of dict "entries" <the entries, as given, after validation>
*/}}
{{- define "keycloak.realmHosts" -}}
{{- $root := .root -}}
{{- $mode := .mode -}}
{{- $entries := (fromYaml (include "keycloak.settings" $root)).realmHosts | default list -}}
{{- if not (kindIs "slice" $entries) -}}
{{- fail (printf "keycloak.realmHosts must be a list or null, got %s (%v). One entry per realm hostname, each a mapping of host and realm plus the fields its routing mode reads - see values.yaml." (kindOf $entries) $entries) -}}
{{- end -}}
{{- $ingress := $root.Values.ingress | default dict -}}
{{- $ingressAdmin := $ingress.admin | default dict -}}
{{- $httpRoute := $root.Values.httpRoute | default dict -}}
{{- /*
THE HOSTNAMES THIS RELEASE ALREADY CLAIMS, collected so that a realm entry
cannot claim one of them a second time. Both modes punish it, differently and
neither of them clearly: under ingress-nginx the admission webhook rejects the
SECOND Ingress on the same host and path - which under Fleet leaves the bundle
in ErrApplied until forceSyncGeneration is raised - while two HTTPRoutes on one
listener and one hostname are MERGED by the implementation, so the duplicate is
accepted and which of the two identical rules serves a request is decided by
object age. The first is a deployment that stops, the second is a coin toss that
looks healthy.
*/ -}}
{{- $claimed := dict -}}
{{- if $ingress.domain -}}
{{- $_ := set $claimed (toString $ingress.domain) "ingress.domain, this release's public hostname" -}}
{{- end -}}
{{- if and (eq (include "keycloak.adminHostnameEnabled" $root) "true") $ingressAdmin.domain -}}
{{- $_ := set $claimed (toString $ingressAdmin.domain) "ingress.admin.domain, the administration console's hostname" -}}
{{- end -}}
{{- if eq $mode "gateway" -}}
{{- range ($httpRoute.hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.hostnames, the public route's explicit hostname list" -}}
{{- end -}}
{{- range (($httpRoute.admin | default dict).hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.admin.hostnames, the admin route's explicit hostname list" -}}
{{- end -}}
{{- end -}}
{{- $seen := dict -}}
{{- range $i, $entry := $entries -}}
{{- $at := printf "keycloak.realmHosts[%d]" $i -}}
{{- if not (kindIs "map" $entry) -}}
{{- fail (printf "%s must be a mapping with at least host and realm, got %s (%v). The list is a list of entries, not of hostnames - the realm cannot be derived from the hostname, and this installation is the proof: id.klein.pub serves the realm klein-family." $at (kindOf $entry) $entry) -}}
{{- end -}}
{{- $host := toString ($entry.host | default "") -}}
{{- $realm := toString ($entry.realm | default "") -}}
{{- if not $host -}}
{{- fail (printf "%s has no host (its realm is %q). An Ingress rule or an HTTPRoute without a hostname is not an error in either API: ingress-nginx treats the empty host as a CATCH-ALL and the object then answers every name the controller serves that nothing else claims, while an HTTPRoute with an empty hostnames list attaches to every hostname of its listener. Both publish this realm's account console across hostnames nobody chose. Set %s.host." $at $realm $at) -}}
{{- end -}}
{{- if not $realm -}}
{{- fail (printf "%s (host %s) has no realm. The realm is the whole point of the entry - it is what the bare / redirects to - and without it the redirect target would read /realms//account/, which Keycloak answers 404 while the hostname, the certificate and the route are all healthy. Set %s.realm, and read it off the realm in Keycloak rather than off the hostname: the two need not match, and in this installation they do not." $at $host $at) -}}
{{- end -}}
{{- if contains "/" $realm -}}
{{- fail (printf "%s has realm %q, which contains a slash. The value is interpolated into the URL path /realms/<realm>/account/, so a slash does not escape it - it silently retargets the redirect at a different path. Use the realm's name as Keycloak shows it." $at $realm) -}}
{{- end -}}
{{- if hasKey $seen $host -}}
{{- fail (printf "%s repeats the host %s, already used by keycloak.realmHosts[%v]. Two entries on one hostname render two objects with the same hostname and the same path: in Ingress mode the admission webhook rejects the second, in Gateway mode both are accepted and merged and the redirect a client gets - so the realm it lands in - depends on which object is older. Give each realm its own hostname, or drop the duplicate entry." $at $host (index $seen $host)) -}}
{{- end -}}
{{- $_ := set $seen $host $i -}}
{{- if hasKey $claimed $host -}}
{{- fail (printf "%s claims the host %s, which this release already publishes through %s. One hostname cannot carry both: a realm entry sends the bare / to /realms/%s/account/, and the hostname it collides with serves the login endpoints or the administration console at / instead. Pick a hostname of its own for the realm - that is what the feature is for - or publish the realm through the existing host without this entry." $at $host (index $claimed $host) $realm) -}}
{{- end -}}
{{- if and (eq $mode "ingress") (not (toString ($entry.tlsSecretName | default ""))) -}}
{{- fail (printf "%s (host %s) has no tlsSecretName, and in Ingress mode it is required. Unlike this chart's own two Ingresses the realm objects do NOT get the conventional tls-<release>-keycloak-ingress name: these hostnames live on certificates that already exist - a wildcard covering several of them, or one per name - and letting the chart invent a secret name per entry would ask cert-manager for a SECOND certificate for a name that is already covered, which costs an ACME order per host and buys nothing. So the name is operator data and has to be given. If the certificate is to be issued for this entry, name a secret of its own here and set %s.clusterIssuer as well." $at $host $at) -}}
{{- end -}}
{{- end -}}
{{- toYaml (dict "entries" $entries) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
+118 -9
View File
@@ -46,16 +46,54 @@ policy. So the Gateway branch of NOTES.txt NAMES it instead of rebuilding it
(decision 2 of #114). Whoever migrates this host sets the equivalent next to
the Gateway, in that implementation's own object.
A NEIGHBOUR ON THE SAME ZONE, WHICH THIS CHART DOES NOT OWN
THE REALM ROUTES, AND WHY THERE IS ONE OBJECT PER ENTRY (Gitea #122)
The same namespace carries four more Ingress objects - keycloak-realm-*-ingress
with nginx.ingress.kubernetes.io/app-root - from the Helm release
jk-cluster-config-fleet-cd-keycloak-realm-ingress, i.e. from ANOTHER
repository. They point at THIS release's Service under four further hostnames
(bk.id..., id.mail..., muth-event.id..., id.klein.pub). They are not rendered
here and must not be: this chart renders exactly the two routes its own two
Ingresses cover. Migrating the host is therefore not finished by switching this
chart - see the wiki page for the list.
keycloak.realmHosts publishes further hostnames onto the SAME Service, each
sending the bare / to one realm's account console. Empty by default, so this
block renders nothing unless an installation fills it.
ONE OBJECT PER ENTRY, never one object with several hostnames. spec.hostnames
applies to the WHOLE object and a rule's matches are path-based, so the realm
could not be told apart inside a shared route: all entries would match
`Exact: /` and whichever rule the implementation picked would send every
hostname to the same realm. The count is therefore data-driven, which is also
why the object names are INDEX-based (style guide §4, as homeassistant does it)
- a realm name is not a dependable object-name fragment and a sanitised one can
collide. The consequence, written down rather than discovered: REORDERING THE
LIST RENAMES ROUTES, so they are recreated rather than edited. A route holds no
state and no certificate, which is what makes that acceptable; the TLS secret
the Ingress mode references is not the chart's and survives untouched either
way.
TWO RULES PER OBJECT, AND THE REDIRECT IS AN EXACT MATCH. The Ingress
counterpart is nginx.ingress.kubernetes.io/app-root, which ingress-nginx renders
as `if ($uri = /) { return 302 $scheme://$http_host<app-root>; }`
(rootfs/etc/nginx/template/nginx.tmpl:1000, controller-v1.14.5) - a 302 on
exactly /, and everything else proxied unchanged. So: an Exact match on / with a
RequestRedirect to /realms/<realm>/account/, plus the ordinary PathPrefix rule
on the Service. The Exact rule wins on / by the specification's precedence
("Exact" path match ahead of prefix matches) INDEPENDENTLY of its position in
the list, so the order below is a rendering convention and not a promise anyone
has to rely on.
statusCode IS WRITTEN OUT, at the value the field already defaults to. 301 and
302 are both Support: Core here, so the portability trap of #119 - an
implementation declining an Extended status code sets Accepted: False on the
WHOLE route, taking its other hostnames down with it - does not apply. The
reason to write it anyway: ingress-nginx emits 302 and a value that is merely
accidentally right documents nothing.
WHAT THE REALM ENTRIES LOSE IN THIS MODE is the per-entry annotations block,
and for three of the four hostnames this installation runs today that means the
proxy buffers - see the paragraph above, which is about the same loss on this
chart's own two objects. It is worse here than there in one respect only: these
hostnames serve ACCOUNT CONSOLE sessions, so the large-header case is the
normal case rather than the edge, and the 502 arrives from the proxy on a
hostname whose Ingress never had a chart value to point at.
ingress.clusterIssuer and the entries' tlsSecretName are not read in this mode:
the certificates for these hostnames sit on the Gateway's listeners, where a
wildcard listener covers whole zones at once.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
@@ -74,6 +112,7 @@ YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "gateway"))).entries -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
@@ -161,4 +200,74 @@ spec:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
{{- /*
INDEX-BASED, like homeassistant's per-host routes and for the same reason
(style guide §4): the count is data-driven and a realm name is not a
dependable object-name fragment - the 63-character label limit plus the
release prefix, and a sanitised name can collide with another entry's. The
price is that reordering keycloak.realmHosts RENAMES these routes, so they are
recreated rather than edited. Which hostname and which realm each index
carries is printed by NOTES.txt, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the chart-global one - the same shape as
httpRoute.admin.parentRefs. It is not decoration here: these hostnames sit in
several DNS zones, so a Gateway that terminates them needs either a wildcard
listener per zone or a named listener per host, and a chart-global parentRef
pinning one sectionName would attach every realm route to one certificate's
listener. A route whose listener does not match its hostname is not an error
either - it is simply not accepted, and the hostname does not answer.
*/}}
parentRefs:
{{- toYaml ($entry.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
- {{ $entry.host | quote }}
rules:
{{- /*
RULE ONE, the counterpart of nginx.ingress.kubernetes.io/app-root. Exact
on / so that only the bare entry point is redirected and every other path
- the realm's own endpoints, its assets, the token endpoints - continues
to be proxied by rule two. Exact outranks every prefix match in the
specification's precedence, so this rule wins on / wherever it is emitted.
*/}}
- matches:
- path:
type: Exact
value: /
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- /*
Written out at the field's own default. 301 and 302 are both
Support: Core, so the trap of #119 - an implementation declining an
Extended code sets Accepted: False on the WHOLE route - is not in
play; ingress-nginx emits 302 for app-root, and a value that is only
accidentally right documents nothing.
*/}}
statusCode: 302
{{- /*
RULE TWO: the same Service, the same port and the same prefix as the
public route. These hostnames are not a second application - they are the
same Keycloak under a name that lands in one realm.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- end }}
+135
View File
@@ -46,6 +46,41 @@ exclusion exists to prevent. The default render is unchanged (ingress.enabled
defaults to true); what changed is a combination that could not be coherent.
*/ -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- /*
THE REALM HOSTNAMES WITH NO MODE TO CARRY THEM (Gitea #122), checked here
because this is the template that owns ingress.enabled and because the check
has to run when NEITHER routing template emits anything. Both modes off is a
legitimate configuration - the chart then publishes nothing and NOTES.txt says
so - but a filled keycloak.realmHosts next to it is not: the list would be
accepted, validated, and rendered into no object at all, which is the one
outcome nobody can tell from a working install by looking at the release.
Emitting nothing is only correct when nothing was asked for.
*/ -}}
{{- if and (not .Values.ingress.enabled) (not (.Values.httpRoute | default dict).enabled) }}
{{- if (fromYaml (include "keycloak.settings" .)).realmHosts }}
{{- fail "keycloak.realmHosts is set while BOTH routing modes are off (ingress.enabled: false and httpRoute.enabled: false). The entries name hostnames and realms and there is no object to put them in: no Ingress, no HTTPRoute, no DNS answer, and nothing in the rendered release that reveals the list was ignored. Switch on the mode these hostnames are meant to be served by, or remove the entries - both are coherent, this combination is not." }}
{{- end }}
{{- end }}
{{- /*
Validated and bound OUTSIDE the ingress.enabled block, because a variable
declared inside an if-block does not exist after it and the realm objects below
need the same list. The helper is only CALLED in Ingress mode: it demands a
tlsSecretName per entry, which the Gateway mode neither reads nor should ask
for.
AND NOT WHEN httpRoute.enabled IS ALSO TRUE, which looks redundant next to
ingress.enabled and is not. That combination is the one keycloak.httproute.yaml
aborts over, with a message about the two data paths; template execution order
is not something a chart can rely on, so a realm check that fired here would
sometimes answer "this entry needs a tlsSecretName" to a values file whose
actual defect is that both modes are on. The specific message must not be able
to hide the structural one. Measured while writing this: without the second
condition, exactly that happened.
*/ -}}
{{- $realmHosts := list }}
{{- if and .Values.ingress.enabled (not (.Values.httpRoute | default dict).enabled) }}
{{- $realmHosts = (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "ingress"))).entries }}
{{- end }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -119,3 +154,103 @@ spec:
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
{{/*
A REALM HOSTNAME (Gitea #122), one Ingress object per keycloak.realmHosts entry.
Keycloak serves every realm from this one deployment, and a realm's account
console lives at /realms/<realm>/account/. An entry here gives a realm a
hostname of its own: the same Service as the two objects above, under a further
name, with the bare / sent to that realm's console by app-root. Gateway mode's
counterpart is an Exact match plus a RequestRedirect filter - see
keycloak.httproute.yaml.
THIS IS WHERE THE CHART DEVIATES FROM THE REPO'S INGRESS CONVENTION, and the
deviation is the expensive half of the feature rather than an oversight. The
convention (style guide §4) is one TLS secret per object named
tls-<release>-<chart>-ingress, with the cert-manager annotation ALWAYS set.
Neither holds here:
- THE SECRET NAME IS OPERATOR DATA, required per entry and never derived.
These hostnames live in zones whose certificates already exist, and several
of them typically share one: a wildcard for a zone covers every realm host
under it. A chart-derived name per entry would ask cert-manager for a fresh
certificate for a name a wildcard already covers - one ACME order per
hostname, for nothing.
- THE ISSUER IS OPTIONAL, and omitting it is the normal case rather than the
exception. cert-manager keys a Certificate by the secret it writes, so two
objects naming one secret and both carrying the annotation request the same
certificate twice. Exactly one object per secret may carry it - which is the
same deliberate omission the qBittorrent object in freesailarr makes, for
the same reason. Where an entry's certificate is to be issued FOR it, it
names a secret of its own and sets clusterIssuer; where the certificate
comes from elsewhere (a wildcard, a Certificate in cluster-config, another
release), clusterIssuer stays empty and this object only consumes the
secret.
The entry's annotations are merged last and per entry, not chart-globally,
because this is not a uniform set: of the four hostnames this installation runs
today, three carry the proxy-buffer pair and one does not. A chart-global
annotation block could not express that difference.
*/}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
{{- /*
INDEX-BASED, like homeassistant's per-host routes and for the same reason
(style guide §4): the count is data-driven, and a realm name is not a
dependable object-name fragment - the 63-character label limit plus the
release prefix, and a sanitised name can collide with another entry's. The
price, written down rather than discovered: reordering keycloak.realmHosts
RENAMES these objects, so they are recreated rather than edited, and the
hostname is briefly unserved while that happens. The TLS secret is not the
chart's and survives it. NOTES.txt prints which index carries which hostname
and realm, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-ingress{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
annotations:
{{- /*
The counterpart of the Gateway mode's Exact-match RequestRedirect.
ingress-nginx renders this annotation as
if ($uri = /) { return 302 $scheme://$http_host<app-root>; }
(rootfs/etc/nginx/template/nginx.tmpl:1000, controller-v1.14.5) - a 302 on
exactly /, with every other path proxied unchanged.
*/}}
nginx.ingress.kubernetes.io/app-root: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- with $entry.clusterIssuer }}
cert-manager.io/cluster-issuer: {{ . | quote }}
{{- end }}
{{- with $entry.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- /*
The entry's own class, falling back to ingress.className - the same shape as
ingress.admin.className. A realm hostname can perfectly well belong to a zone
served by a different controller than the login host.
*/}}
ingressClassName: {{ $entry.className | default $.Values.ingress.className }}
rules:
- host: {{ $entry.host | quote }}
http:
paths:
{{- /*
The SAME backend as the two objects above, by port NAME as they do.
Everything but the bare / is proxied straight through; app-root
handles /.
*/}}
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ $entry.host | quote }}
secretName: {{ $entry.tlsSecretName | quote }}
{{- end }}
+42
View File
@@ -151,6 +151,48 @@
"extraVolumeMounts": {
"description": "Additional volume mounts, rendered verbatim and TWICE - on the build init container as well as on the serving container (Gitea #88). That is deliberate: a login theme is read at runtime, but an SPI provider JAR must be present while `kc.sh build` writes the provider registry, or it is never registered and nothing reports it. Items loosely typed for the same reason as extraVolumes. Never mount on /opt/keycloak/themes itself - it would hide the image's base, keycloak and keycloak.v2 themes; use a subpath /opt/keycloak/themes/<name>.",
"type": ["array", "null"]
},
"realmHosts": {
"description": "One hostname per realm (Gitea #122), rendered in BOTH routing modes: one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter to /realms/<realm>/account/. Empty by default and then renders nothing at all. One object per entry, named by list INDEX - so reordering the list renames and recreates them. host and realm are required and the realm is NOT derived from the hostname; the render also aborts on a repeated host and on a host this release already publishes, because one hostname cannot serve two different things at /. tlsSecretName is required in Ingress mode and never derived, and clusterIssuer is optional there - the documented deviation from the one-secret/always-annotate convention, so that a certificate already covering these zones is consumed rather than requested a second time. See values.yaml for the full reasoning and for the order a takeover of existing hostnames has to run in.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["host", "realm"],
"properties": {
"host": {
"description": "The hostname serving this realm. Must differ from every other entry's and from the hostnames this release already publishes.",
"type": "string",
"minLength": 1
},
"realm": {
"description": "The realm as Keycloak names it, interpolated into the redirect target /realms/<realm>/account/. Not derivable from the hostname - the two need not correspond and in the installation this was written for they do not. A slash in it aborts the render, because it would retarget the redirect instead of being escaped.",
"type": "string",
"minLength": 1
},
"tlsSecretName": {
"description": "The TLS secret this hostname's certificate lives in. REQUIRED IN INGRESS MODE and deliberately not derived from release and chart name: these zones already have certificates, often one wildcard for several entries, and a derived name would mean one further ACME order per hostname for a name that is already covered. Not read in Gateway mode - there the certificate sits on the Gateway's listener.",
"type": ["string", "null"]
},
"clusterIssuer": {
"description": "Optional cert-manager issuer for THIS object. Empty or absent renders no cert-manager annotation, which is the normal case: cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry the annotation. Same deliberate omission as the qBittorrent object in freesailarr. Not read in Gateway mode.",
"type": ["string", "null"]
},
"className": {
"description": "Optional ingress class for this object, falling back to ingress.className - the same shape as ingress.admin.className, because a realm hostname may belong to a zone served by another controller. Not read in Gateway mode, where the class is a property of the Gateway.",
"type": ["string", "null"]
},
"annotations": {
"description": "Extra annotations for THIS entry's Ingress, merged after the app-root and cert-manager annotations. Per entry rather than chart-global on purpose: the set is not uniform across hostnames - of the four this was written for, three carry the proxy-buffer pair and one does not. Free-form keys by nature, so this object stays open. INERT in Gateway mode, and for an account console that loses the proxy buffers in the case where large headers are the norm rather than the edge.",
"type": "object"
},
"parentRefs": {
"description": "Optional Gateway(s) for this entry's HTTPRoute, falling back to httpRoute.parentRefs - the counterpart of className above, because \"which data path\" is a property of the Gateway in this API. Not decoration here: realm hostnames sit in several DNS zones, so a chart-global parentRef pinning one sectionName would attach every realm route to one certificate's listener, and a route whose listener does not match its hostname is not accepted and simply does not answer. Not read in Ingress mode.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
}
}
}
},
+114
View File
@@ -614,6 +614,120 @@ keycloak:
extraVolumes: []
extraVolumeMounts: []
# ONE HOSTNAME PER REALM (Gitea #122). Empty by default, and an empty list
# renders NOTHING in either mode - the default render is byte-identical to a
# release without this feature.
#
# WHAT IT IS FOR. Keycloak serves every realm from this one deployment, and a
# realm's account console lives at /realms/<realm>/account/. An entry here
# gives one realm a hostname of its own: this release's Service published
# under a further name, with the bare / sent to that realm's console and
# everything else proxied through unchanged. It renders in BOTH routing modes
# - one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one
# HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter.
#
# ONE OBJECT PER ENTRY, never one object with several hostnames: an
# HTTPRoute's hostnames apply to the whole object and a rule's matches are
# path-based, so inside a shared route every entry would match `Exact: /` and
# the realm could not be told apart. The objects are named by LIST INDEX
# (<release>-keycloak-realm-ingress, -realm-ingress-1, ... and the same for
# -realm-httproute), which means REORDERING THIS LIST RENAMES THEM and they
# are recreated rather than edited. NOTES.txt prints the index-to-hostname
# mapping, because the object name does not carry it.
#
# THE REALM IS NOT DERIVABLE FROM THE HOSTNAME and the chart does not try:
# both are given per entry. In the installation this feature was written for,
# one of the four hostnames serves a realm whose name shares nothing with it.
#
# Fields, with the mode that reads each one:
#
# host (both) REQUIRED. The hostname. Must not repeat
# another entry's, and must not be a hostname
# this release already publishes
# (ingress.domain, ingress.admin.domain, an
# httpRoute hostname) - the render aborts on
# either, because one hostname cannot serve two
# different things at /.
# realm (both) REQUIRED. The realm as Keycloak names it. It
# is interpolated into /realms/<realm>/account/.
# tlsSecretName (Ingress) REQUIRED in Ingress mode, see below.
# clusterIssuer (Ingress) OPTIONAL, see below. Empty or absent = no
# cert-manager annotation on this object.
# annotations (Ingress) OPTIONAL, per entry. Merged last.
# className (Ingress) OPTIONAL. Falls back to ingress.className.
# parentRefs (Gateway) OPTIONAL. Falls back to httpRoute.parentRefs.
#
# realmHosts:
# - host: realm-one.id.example.com
# realm: realm-one
# tlsSecretName: tls-id-wildcard-ingress # a certificate that exists
# clusterIssuer: "" # issued elsewhere, see below
# annotations:
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
# - host: id.other.example.com
# realm: realm-two
# tlsSecretName: tls-id-other-ingress
# clusterIssuer: letsencrypt-issuer # issued for THIS object
#
# (Neutral examples on purpose. Realm names, hostnames and secret names are
# operator data and are not in this chart - they belong in the values file of
# the installation.)
#
# WHY THIS DEVIATES FROM THE REPO'S INGRESS CONVENTION, which is one TLS
# secret named tls-<release>-<chart>-ingress and the cert-manager annotation
# ALWAYS set (style guide §4). Neither holds for these objects, and both
# deviations are deliberate:
#
# - tlsSecretName is REQUIRED AND NEVER DERIVED. These hostnames live in
# zones whose certificates already exist, and several entries typically
# share one - a wildcard for a zone covers every realm host under it. A
# chart-derived name per entry would ask cert-manager for a fresh
# certificate for a name a wildcard already covers: one ACME order per
# hostname, for nothing. So the name is operator data, and an entry
# without one aborts the render in Ingress mode rather than rendering a
# TLS block that silently serves the controller's default certificate.
# - clusterIssuer is OPTIONAL, and leaving it empty is the normal case.
# cert-manager keys a Certificate by the secret it writes, so two objects
# naming one secret and both carrying the annotation request the same
# certificate twice. Exactly one object per secret may carry it - the same
# deliberate omission the qBittorrent object in freesailarr makes, for the
# same reason. Set it only where the certificate is to be issued FOR this
# entry's own secret.
#
# ANNOTATIONS ARE PER ENTRY and not chart-global, because the set is not
# uniform: of the four hostnames the installation this was written for runs
# today, three carry the proxy-buffer pair and one does not. One global block
# could not express that difference. In GATEWAY MODE this field is inert, like
# ingress.annotations - and for these hostnames that loss bites harder than
# for the chart's own two, because an account console session is exactly the
# large-header case: see the httpRoute block below and NOTES.txt.
#
# TAKING OVER HOSTNAMES THAT ANOTHER RELEASE SERVES TODAY - THE ORDER MATTERS,
# AND IT IS NOT REVERSIBLE WITHIN A DEPLOY. These objects may already exist in
# the cluster, created by a different repository and owned by a different Helm
# release. Filling this list then does not edit them, it adds a SECOND object
# for the same hostname and path, and the two orders are not equally bad:
#
# 1. Remove the objects in the owning repository FIRST and let that removal
# reach the cluster.
# 2. THEN install this chart with the list filled.
#
# The window between the two is a short unreachability of those hostnames.
# The other order, or both at once, is worse: ingress-nginx's admission
# webhook rejects the second claim on a host-and-path pair, and under Fleet
# that leaves the bundle in ErrApplied until forceSyncGeneration is raised -
# measured, and written down in ytdlp-web-player's collision fail. In Gateway
# mode the webhook never fires (it compares Ingresses with Ingresses), and the
# duplicate is accepted instead: two merged routes on one hostname, with
# object age deciding which redirect a client gets.
#
# AND ONE QUESTION THIS CHART CANNOT ANSWER, UNCHECKED AS OF THIS WRITING:
# whether anything in the owning repository depends on those objects BY NAME -
# a Fleet bundle, a monitor, an objectset reference. Clear that before step 1,
# not after.
realmHosts: []
ingress:
# The PUBLIC ingress - the login endpoint, the token endpoints, the account
# console. On by default, because an identity provider nothing can reach is
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/static/logo
type: application
version: 2.1.0+up2026.09.30
version: 2.1.1+up2026.09.30
appVersion: "2026-09-30"
maintainers:
@@ -2,8 +2,11 @@
The Gateway-API alternative to rss-bridge.ingress.yaml (Gitea #114), and the
LAST chart of that sweep: 22 charts carried an Ingress template and 21 an
HTTPRoute template, and the difference was this one. It was held back because
its install-test fails on the Gitea runner (#51), not for anything about its
shape.
its install-test had been failing on the Gitea runner (#51), not for anything
about its shape - and that test went green in the very PR that converted it,
on a render byte-identical to main, which is what ruled the chart out and left
the runner. #51 was closed on the strength of that round; the cause was never
identified, so a recurrence is a fresh finding rather than a known quirk.
WHAT THIS RENDERS, AND WHAT IT DELIBERATELY DOES NOT
+5 -3
View File
@@ -272,9 +272,11 @@ ingress:
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS ABOVE (Gitea #114), and this chart
# is the LAST of that sweep - 22 charts carried an Ingress template, 21 an
# HTTPRoute template, and the difference was this one. It was held back because
# its install-test fails on the Gitea runner (#51), not for anything about its
# shape, and that test is still red: the failure is a known, bounded
# environment defect and no finding about this mode.
# its install-test had been failing on the Gitea runner (#51), not for anything
# about its shape - and that test went green in the very PR that converted it,
# on a render byte-identical to main, which ruled the chart out and left the
# runner. #51 was closed on that round; the cause was never identified, so a
# recurrence is a fresh finding rather than a known quirk.
#
# Off by default, so an existing release is unaffected. Mutually exclusive with
# ingress.enabled - the render aborts when both are true, naming both value
+11 -8
View File
@@ -18,14 +18,17 @@ type: application
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
# apart, so either floating name would change the running image with no commit
# in this repository and nothing to report it.
# MINOR, not major, and the evidence is a diff rather than a judgement: the
# per-group spread constraint and the per-group PodDisruptionBudget of Gitea #96
# are both gated on the COMPUTED replica count being >= 2, and the chart ships
# full: 1 / mx: 0. `helm template` against the previous version therefore renders
# byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml
# - measured, both empty diffs. New optional values with unchanged defaults is
# exactly the minor case (README, "Versioning").
version: 1.2.0+upv0.16.25
# MINOR, not major, and the evidence is a diff rather than a judgement. That
# held for the per-group spread constraint and PodDisruptionBudget of Gitea #96
# (both gated on the COMPUTED replica count being >= 2, and the chart ships
# full: 1 / mx: 0) and it holds again for the MTA-STS routes of Gitea #123: the
# list is the switch and it ships EMPTY, so no object is added and no value
# becomes required in either routing mode. `helm template` against the previous
# version renders byte-for-byte identically for the defaults AND for
# ci/stalwart/test-values.yaml as it stands on origin/main - measured, both
# empty diffs. New optional values with unchanged defaults is exactly the minor
# case (README, "Versioning").
version: 1.3.0+upv0.16.25
appVersion: "v0.16.25"
maintainers:
+77
View File
@@ -22,6 +22,8 @@ WebUI and JMAP are reachable from outside at all (Gitea #114).
{{- end -}}
{{- $ingressGroup := "" -}}
{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
{{- $mtaStsHosts := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
{{- $mtaStsPath := include "stalwart.mtaSts.path" . -}}
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
@@ -327,6 +329,42 @@ WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: fals
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
database (step 3). Between finishing setup and creating that listener the
Ingress resolves to a port that answers nothing.
{{- if $mtaStsHosts }}
MTA-STS — {{ len $mtaStsHosts }} POLICY ROUTE(S), INGRESS MODE
All of them → node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (pathType Exact)
{{ range $entry := $mtaStsHosts }}
https://{{ $entry.host }}{{ $mtaStsPath }}
secret {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested from the "{{ $entry.clusterIssuer }}" cluster issuer{{ else }}, NO cluster issuer set — this release does not request that certificate, it has to exist already{{ end }}
{{- end }}
One Ingress object PER CLUSTER ISSUER, not one per host: the cert-manager
issuer is an object-level ANNOTATION while the TLS secret is a per-entry
field, so one object cannot both request a certificate and leave another
host's secret alone. Hosts sharing a secret share one tls: entry, so one
certificate with several names is requested once instead of once per host:
kubectl -n {{ .Release.Namespace }} get ingress -l app={{ $release }}
TWO THINGS THIS RELEASE DOES NOT DO, AND BOTH ARE SILENT WHEN MISSING:
1. IT DOES NOT CREATE THE DOMAIN. The domains live in Stalwart's data
store (step 3), not in any chart value, so a route to a domain that is
not in the data store answers 404 at a perfectly routed endpoint —
every object here green.
2. IT DOES NOT CREATE THE DNS RECORD. MTA-STS also needs a TXT record at
_mta-sts.<domain>; without it no sending MTA ever looks for the policy.
AND THE FAILURE IS ON THE SENDER'S SIDE, NOT HERE: an MTA that finds no
policy — or hits a certificate that does not match — just delivers in the
clear and logs nothing in this cluster. So verify each one from outside:
{{- range $entry := $mtaStsHosts }}
curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
MIND THE DEEPER NAMES: mta-sts.sub.<domain> is one label below
*.<domain>, and a wildcard certificate matches exactly one label — so a
parent wildcard does NOT cover it. That is what the per-entry secret is for.
{{- end }}
{{- else if $httpRoute.enabled }}
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
@@ -417,6 +455,38 @@ WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: fals
here. Upstream moves administration to https://<the configured server
hostname>/admin once setup is complete, so this route only works while an
HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3).
{{- if $mtaStsHosts }}
MTA-STS — ONE ROUTE, {{ len $mtaStsHosts }} HOSTNAME(S), GATEWAY MODE
{{- range $entry := $mtaStsHosts }}
https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
→ node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (type: Exact)
ONE HTTPRoute ({{ $release }}-mta-sts-httproute) for all of them, attached to
the same Gateway(s) as the WebUI route above. Nothing differs per host here —
same path, same backend, no filter — so a hostname list is the whole object:
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-mta-sts-httproute -o yaml
THE TLS FIELDS OF EACH ENTRY ARE INERT IN THIS MODE. tlsSecretName and
clusterIssuer are read only on the Ingress path; here the certificate belongs
to the Gateway's LISTENER, and this release renders none for these hostnames.
So before switching these hosts over, check the listener really covers every
one of them — AND MIND THE DEPTH: mta-sts.sub.<domain> is one label below
*.<domain>, and a wildcard matches exactly one label, so a wildcard listener
for the parent domain does NOT terminate TLS for it. The symptom is a TLS
error at the SENDING MTA, which nothing in this cluster logs.
TWO THINGS THIS RELEASE STILL DOES NOT DO, both silent when missing: it does
not create the DOMAIN in Stalwart's data store (step 3) — a route to a domain
that is not there answers 404 at a correctly routed endpoint — and it does
not create the _mta-sts.<domain> TXT record, without which no sending MTA
looks for the policy at all. An MTA that finds no policy delivers in the
clear and reports nothing here, so verify each one from outside:
{{- range $entry := $mtaStsHosts }}
curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
{{- end }}
{{- else }}
NEITHER INGRESS NOR HTTPROUTE
@@ -424,6 +494,13 @@ NEITHER INGRESS NOR HTTPROUTE
no HTTPRoute and no WebUI certificate.
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
dnsNames, and without it the certificate would carry no name at all.
{{- if $mtaStsHosts }}
AND mtaSts.hosts IS SET ({{ len $mtaStsHosts }} entr{{ if eq (len $mtaStsHosts) 1 }}y{{ else }}ies{{ end }}) BUT RENDERS NOTHING. Those routes need a
routing mode: they follow the one that is on, and with both off there is no
object to carry them. Nothing is wrong with the list — switch on
ingress.enabled or httpRoute.enabled and the routes appear with it.
{{- end }}
{{- end }}
{{- if .Values.valkeySubchart.enabled }}
+165
View File
@@ -556,3 +556,168 @@ The node group the Ingress routes to, validated. Input: the root context.
{{- end -}}
{{- $group -}}
{{- end -}}
{{/*
MTA-STS: the policy path, RFC 8461, hardcoded in one place.
NOT A VALUE, and that is a decision rather than an omission. RFC 8461 fixes the
location - the policy is retrieved over HTTPS from the well-known path
/.well-known/mta-sts.txt on mta-sts.<domain>. A sending MTA looks there and
nowhere else, so a values key for it would be a key that can only ever be set
WRONG, and the failure is silent on this side (see the mtaSts block in
values.yaml). Both routing modes include this define, so the Ingress path and
the HTTPRoute path cannot drift apart.
*/}}
{{- define "stalwart.mtaSts.path" -}}/.well-known/mta-sts.txt{{- end -}}
{{/*
The validated, normalised mtaSts.hosts list. Input: the root context.
Returns YAML of a list of dicts with the keys host, tlsSecretName and
clusterIssuer (the latter two possibly empty), in the order they were written.
Shared by BOTH routing modes, which is why the host-level validation lives here
and not in either template: an entry that is wrong is wrong in both modes, and
a guard that exists in only one of them is a guard a mode switch removes.
*/}}
{{- define "stalwart.mtaSts.hosts" -}}
{{- $mtaSts := .Values.mtaSts | default dict -}}
{{- $entries := $mtaSts.hosts | default list -}}
{{- $out := list -}}
{{- $seen := dict -}}
{{- range $i, $entry := $entries -}}
{{- $entry = $entry | default dict -}}
{{- $host := required (printf "mtaSts.hosts[%d] has no host - every entry needs the hostname the policy is served on, which RFC 8461 fixes as mta-sts.<domain>" $i) $entry.host -}}
{{- if hasKey $seen $host -}}
{{- /*
An error in BOTH modes, which is why it aborts rather than being de-duplicated.
In Ingress mode two rules on one host and one path are what ingress-nginx's
admission webhook rejects - but only once the two land in DIFFERENT objects,
and whether they do depends on the clusterIssuer of each entry, so the loud
failure would come and go with an unrelated field. In Gateway mode a repeated
entry in spec.hostnames is accepted and does nothing at all.
*/ -}}
{{- fail (printf "mtaSts.hosts[%d] repeats the host %q, which is already entry %d. One hostname serves one MTA-STS policy; a second entry for it either collides in the ingress-nginx admission webhook or is silently ignored by a Gateway, depending on the mode and on the clusterIssuer fields - so it is refused here instead." $i $host (index $seen $host)) -}}
{{- end -}}
{{- $_ := set $seen $host $i -}}
{{- $out = append $out (dict
"host" $host
"tlsSecretName" ($entry.tlsSecretName | default "")
"clusterIssuer" ($entry.clusterIssuer | default "")) -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
MTA-STS in INGRESS MODE: the entries grouped into the Ingress objects that
actually have to exist. Input: the root context. Returns YAML of
order: list of clusterIssuer values, first-appearance order ("" included)
groups: <clusterIssuer> -> dict with
hosts: list of hosts, one rule each
tlsOrder: list of TLS secret names, first-appearance order
tls: <secretName> -> list of hosts sharing it
WHY THIS IS NOT ONE INGRESS OBJECT, AND THE REASON IS MEASURED
The approved plan on Gitea #123 asks for "one Ingress, one rule per host, tls
grouped by secret" (point 4) AND for a per-entry, optional clusterIssuer whose
empty value means no annotation (point 2) AND for an overlay carrying one entry
with an issuer and one without (point 9). Those three cannot all hold, because
of where cert-manager reads each of the two fields:
- cert-manager.io/cluster-issuer is an ANNOTATION, i.e. OBJECT-scoped. One
Ingress object carries exactly one of them, or none - and with the
annotation absent, cert-manager's ingress-shim creates NOTHING for that
object, for every tls entry on it rather than for some.
- secretName is TLS-ENTRY-scoped, and the shim creates one Certificate PER
tls entry.
So an object cannot mix "issued by X" with "comes from somewhere else", and the
split has to be on the issuer. Measured in jk-cluster on 2026-10-13, which is
also where the second half of the rule comes from:
Certificate tls-mta-sts-bekemeyer-klein-de-ingress
secretName: tls-mta-sts-bekemeyer-klein-de-ingress
ownerReferences: Ingress/mta-sts-ingress
Certificate tls-stalwart-stalwart-ingress
secretName: tls-stalwart-stalwart-ingress
ownerReferences: Ingress/stalwart-stalwart-ingress
The shim names the Certificate AFTER THE SECRET and owns it from the Ingress.
Two Ingress objects whose tls entries name the same secret therefore drive the
shim at ONE Certificate name from two owners - so a secret shared by several
hosts must sit in exactly one object, which is what the tls grouping below is
for, and a secret may NOT span two issuer groups. That last case is the one
thing this helper refuses outright.
The grouping itself is rss-bridge.tlsGroups (rss-bridge/templates/_helpers.tpl),
adopted rather than reinvented as #123 point 4 asks; what is new here is the
issuer dimension, which that chart does not have - it sets one chart-wide
ingress.clusterIssuer on its single object.
*/}}
{{- define "stalwart.mtaSts.ingressGroups" -}}
{{- $entries := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
{{- $order := list -}}
{{- $groups := dict -}}
{{- $secretIssuer := dict -}}
{{- range $i, $entry := $entries -}}
{{- $host := $entry.host -}}
{{- $issuer := $entry.clusterIssuer -}}
{{- /*
REQUIRED HERE AND NOWHERE ELSE. In Gateway mode TLS sits on the Gateway's
listener and this chart renders no certificate for it, so demanding the field
there would be demanding a value that does nothing (#123, and the same split as
Gitea #122). There is deliberately NO DEFAULT: the plausible one - this
release's own tls-<release>-<chart>-ingress - is the WRONG certificate. It
covers ingress.domain and not mta-sts.<domain>, and a sending MTA would see a
name mismatch, give up on the policy and send in the clear, with nothing
logged on this side.
*/ -}}
{{- $secret := required (printf "mtaSts.hosts[%d] (%s) has no tlsSecretName, and in Ingress mode every entry needs one: MTA-STS is fetched over HTTPS only, so a host without a certificate serves no policy at all. There is no default on purpose - this release's own tls-<release>-<chart>-ingress covers ingress.domain, not this host. Point it at the Secret the certificate for %s lives in, and set clusterIssuer as well if cert-manager is the one to request it." $i $host $host) $entry.tlsSecretName -}}
{{- if hasKey $secretIssuer $secret -}}
{{- if ne (index $secretIssuer $secret) $issuer -}}
{{- fail (printf "mtaSts.hosts[%d] (%s) puts the TLS secret %q under clusterIssuer %q, but an earlier entry already uses that secret under clusterIssuer %q. cert-manager's ingress-shim names the Certificate after the secret and owns it from the Ingress object, while the issuer is an object-level annotation - so the two entries would need two Ingress objects both driving one Certificate name, from two owners. Give the two hosts separate secrets, or put them under the same issuer." $i $host $secret $issuer (index $secretIssuer $secret)) -}}
{{- end -}}
{{- end -}}
{{- $_ := set $secretIssuer $secret $issuer -}}
{{- if not (hasKey $groups $issuer) -}}
{{- $_ := set $groups $issuer (dict "hosts" list "tlsOrder" list "tls" dict) -}}
{{- $order = append $order $issuer -}}
{{- end -}}
{{- $group := index $groups $issuer -}}
{{- $_ := set $group "hosts" (append $group.hosts $host) -}}
{{- if not (hasKey $group.tls $secret) -}}
{{- $_ := set $group.tls $secret list -}}
{{- $_ := set $group "tlsOrder" (append $group.tlsOrder $secret) -}}
{{- end -}}
{{- $_ := set $group.tls $secret (append (index $group.tls $secret) $host) -}}
{{- end -}}
{{- toYaml (dict "order" $order "groups" $groups) -}}
{{- end -}}
{{/*
The name of the MTA-STS Ingress object for one clusterIssuer group. Input: dict
"root" <root context> "issuer" <the group's clusterIssuer, possibly "">.
NOT AN INDEX, and the reason is the one exception the comparable precedent
names itself. homeassistant carries the entry's index in its HTTPRoute names
(Gitea #114, group 3) and writes down that reordering the list therefore
recreates rather than edits the objects - "harmless (a route holds no state or
certificate)". These objects DO hold a certificate: cert-manager owns the
Certificate from the Ingress, so a rename deletes one Certificate and creates
another. The Secret survives that - cert-manager does not delete it with the
Certificate - so no re-issue follows and nothing breaks, but it is churn on an
ACME account in return for a reordered list, and the issuer is a stable key
that costs nothing to use instead.
The issuer-less group takes the unsuffixed name, and that cannot collide with
an issuer's: a clusterIssuer name is never the empty string, so no suffix is
ever empty.
*/}}
{{- define "stalwart.mtaSts.ingressName" -}}
{{- $base := printf "%s-mta-sts" (include "stalwart.fullname" .root) -}}
{{- if .issuer -}}
{{- printf "%s-%s-ingress" $base .issuer -}}
{{- else -}}
{{- printf "%s-ingress" $base -}}
{{- end -}}
{{- end -}}
@@ -163,3 +163,86 @@ spec:
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port: {{ int (index $ports "http").containerPort }}
{{- end }}
{{- /*
MTA-STS (Gitea #123) - the policy route, in Gateway mode.
ONE OBJECT WITH ALL THE HOSTNAMES, and here that is right where the Ingress
side needs several objects. spec.hostnames applies to the whole object and
nothing differs per host: same path, same backend, no filter. So one object
with a hostname list says exactly what is meant. (The contrast worth knowing:
Gitea #122 needs one object per host for keycloak, because its app-root
redirect differs per realm and a rule's matches are path-based - there is no
way to tell hostnames apart inside one route. And the Ingress side here needs
several objects for a third, unrelated reason: the cert-manager annotation is
object-scoped. Three different shapes, three different causes.)
NO TLS HERE, and therefore no use for either TLS field of an entry.
tlsSecretName and clusterIssuer are accepted and INERT in this mode - the
listener on the Gateway carries the certificate, and this chart renders no
certificate for an HTTP hostname in Gateway mode. Which is why tlsSecretName is
required only on the Ingress path: a value demanded in a mode where it does
nothing is a value that teaches the wrong thing.
AND THE HOSTNAMES ARE A TRAP WORTH NAMING AT THE LISTENER. mta-sts.sub.domain.tld
is one label deeper than *.domain.tld, so a wildcard listener for the parent
does NOT terminate TLS for it (RFC 6125: a wildcard matches one label). The
Gateway needs a listener that really covers each hostname below, and the
symptom of getting it wrong is a TLS error at the SENDING MTA - nothing in this
cluster logs it.
NO SEPARATE parentRefs. The route attaches to the same Gateway(s) as the WebUI
route above, which is the honest default: these hostnames point at the same
Service in the same namespace. If they ever need a Gateway of their own, the
shape to copy is keycloak's httpRoute.admin.parentRefs (Gitea #114, group 3) -
an own list falling back to httpRoute.parentRefs - and not a second required
value. The abort on an empty httpRoute.parentRefs above covers this object too:
it runs whenever httpRoute.enabled is true, before anything here is rendered.
*/ -}}
{{- if $httpRoute.enabled }}
{{- $mtaStsHosts := fromYamlArray (include "stalwart.mtaSts.hosts" .) }}
{{- if $mtaStsHosts }}
{{- $group := include "stalwart.ingressNodeGroup" . }}
{{- $nodes := fromYaml (include "stalwart.nodes" .) }}
{{- $ports := fromYaml (include "stalwart.ports" .) }}
{{- $spec := index $nodes $group }}
{{- if not (has "http" $spec.exposedPorts) }}
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the MTA-STS HTTPRoute to point at" $group $group) }}
{{- end }}
{{- $parentRefs := $httpRoute.parentRefs | default list }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "stalwart.fullname" . }}-mta-sts-httproute
labels:
app: {{ include "stalwart.fullname" . }}
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- range $entry := $mtaStsHosts }}
- {{ $entry.host }}
{{- end }}
rules:
{{- /*
type: Exact, not PathPrefix - RFC 8461 serves exactly one file at
exactly one path, and a prefix here would publish the mail server's
whole management surface under these hostnames.
The port is the Service's port NUMBER, from ports.http.containerPort,
exactly as in the WebUI route above: backendRefs takes a PortNumber, so
the name "http" the Ingress uses is not available here. Note that this
chart's NodePort Services publish a DIFFERENT number
(ports.<name>.nodePort) than the container binds, so the wrong one is a
plausible mistake - ci/run-httproute-match-test.sh holds this reference
against the Services of the same render.
*/}}
- matches:
- path:
type: Exact
value: {{ include "stalwart.mtaSts.path" . }}
backendRefs:
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port: {{ int (index $ports "http").containerPort }}
{{- end }}
{{- end }}
+104
View File
@@ -63,3 +63,107 @@ spec:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ include "stalwart.fullname" . }}-ingress
{{- end }}
{{- /*
MTA-STS (Gitea #123) - the policy route, in Ingress mode.
ONE OBJECT PER clusterIssuer, one rule per host, tls grouped by secret. The
whole reasoning for that shape, including the cert-manager measurement it rests
on, is in stalwart.mtaSts.ingressGroups in _helpers.tpl; the short version is
that the issuer is an object-level ANNOTATION while the secret is a per-tls-entry
field, so an object cannot both ask cert-manager for one certificate and leave
another alone.
GATED ON ingress.enabled, not on a switch of its own. mtaSts.hosts is the
switch for the feature (empty list = nothing rendered, in either mode) and
ingress.enabled is the switch for the MODE - so these objects appear exactly
when this chart is publishing over an Ingress AND the list is non-empty. The
mutual exclusion of the two modes is enforced once, in stalwart.httproute.yaml,
and it is unconditional on mtaSts: a fail anywhere aborts the whole render.
AND "not httpRoute.enabled" IS PART OF THAT GATE RATHER THAN REDUNDANT WITH
IT. Without it this block's per-entry required() can fire while BOTH modes are
on, and then the render reports "mtaSts.hosts[0] has no tlsSecretName, and in
Ingress mode every entry needs one" - true, and the wrong diagnosis: the
values file's defect is that both modes are on, and "in Ingress mode" is a
claim about a configuration that is not coherent at all. Whoever met it would
add a tlsSecretName, re-run, and reach the real message one round later, or
conclude this feature was the problem. MEASURED on this chart before the gate
was added, not reasoned about.
Ordering is not the fix and could not be: Helm renders templates in an order a
chart cannot pin, so this template may not assume the httproute template's
structural abort runs first. SCOPE is the fix - with both modes on this block
validates nothing, so the only fail left is the one that names the real
problem. The generalisation (a specific validation must not be reachable in a
state a structural validation already rejects) is the Lessons-Learned entry
that the parallel keycloak round of Gitea #122 bought on its own realm list.
WHAT THIS DELIBERATELY DOES NOT CARRY: ingress.annotations. Those belong to the
WebUI/JMAP host - on the live installation they are its CORS configuration for
the bulwark webmail - and a policy file served to sending MTAs has no use for
them. Copying them here would put a CORS allow-origin on an endpoint no browser
ever calls, and would do it silently.
AND THE LIMIT THAT NO OBJECT HERE CAN FIX: this chart registers a ROUTE, it
does not create a DOMAIN. Whether Stalwart answers for mta-sts.<domain> depends
on <domain> existing in its data store, which is not in any chart value (see
values.yaml, line 1 onwards, and the mtaSts block).
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict }}
{{- if and .Values.ingress.enabled (not $httpRoute.enabled) }}
{{- $mtaSts := fromYaml (include "stalwart.mtaSts.ingressGroups" .) }}
{{- if $mtaSts.order }}
{{- $group := include "stalwart.ingressNodeGroup" . }}
{{- $nodes := fromYaml (include "stalwart.nodes" .) }}
{{- $spec := index $nodes $group }}
{{- if not (has "http" $spec.exposedPorts) }}
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the MTA-STS Ingress to point at" $group $group) }}
{{- end }}
{{- $service := printf "%s-%s-http-service" (include "stalwart.fullname" .) $group }}
{{- $path := include "stalwart.mtaSts.path" . }}
{{- range $issuer := $mtaSts.order }}
{{- $entry := index $mtaSts.groups $issuer }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "stalwart.mtaSts.ingressName" (dict "root" $ "issuer" $issuer) }}
labels:
app: {{ include "stalwart.fullname" $ }}
{{- if $issuer }}
annotations:
cert-manager.io/cluster-issuer: {{ $issuer }}
{{- end }}
spec:
ingressClassName: {{ $.Values.ingress.className }}
rules:
{{- range $host := $entry.hosts }}
- host: {{ $host }}
http:
paths:
{{- /*
Exact, not Prefix, and the same per-group http Service the WebUI
rule above names - by PORT NAME, which an Ingress backend can do
and a Gateway backendRef cannot. RFC 8461 serves exactly one file
at exactly one path; a Prefix here would hand this host's whole
URL space to the mail server's management surface.
*/}}
- backend:
service:
name: {{ $service }}
port:
name: http
path: {{ $path }}
pathType: Exact
{{- end }}
tls:
{{- range $secret := $entry.tlsOrder }}
- hosts:
{{- range $host := index $entry.tls $secret }}
- {{ $host }}
{{- end }}
secretName: {{ $secret }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+33
View File
@@ -433,6 +433,18 @@
}
}
},
"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"],
@@ -478,6 +490,27 @@
"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",
+155
View File
@@ -756,6 +756,161 @@ httpRoute:
# hostname is reachable without the application agreeing that it is its name.
hostnames: []
# ---------------------------------------------------------------------------
# MTA-STS — the policy route, in BOTH routing modes (Gitea #123)
# ---------------------------------------------------------------------------
# MTA-STS lets a sending mail server find out, over HTTPS, that this domain
# expects TLS. Stalwart serves the policy file itself — this block is only
# about ROUTING a sending MTA to it, and the route has to exist in whichever
# mode this chart is publishing in.
#
# WHAT IS RENDERED, AND IT IS NOT THE SAME SHAPE ON BOTH SIDES:
#
# Ingress mode — ONE Ingress object PER DISTINCT clusterIssuer, each with one
# rule per host and its tls: list grouped by secret name.
# Several objects because cert-manager's cluster-issuer is an
# object-level ANNOTATION while secretName is a per-tls-entry
# field, so one object cannot both request a certificate from
# issuer X and leave another host's secret alone. The full
# argument, with the cert-manager behaviour it was measured
# against, is in stalwart.mtaSts.ingressGroups in
# _helpers.tpl.
# Gateway mode — ONE HTTPRoute with all the hostnames. Nothing differs per
# host here (same path, same backend, no filter) and
# spec.hostnames is a list, so one object says exactly what
# is meant. TLS sits on the Gateway's listener and this chart
# renders no certificate for it.
#
# Both point at the same per-group http Service the WebUI route points at
# (ingress.nodeGroup), on the one path RFC 8461 allows.
#
# THE LIST IS THE SWITCH. There is deliberately no mtaSts.enabled: "enabled
# with an empty list" would be a state with no meaning that the chart would
# then have to catch. Empty list — the default — renders nothing in either
# mode, so this block changes nothing about a running installation until it is
# filled.
#
# THE PATH IS HARDWIRED and is not a value. RFC 8461 fixes it: the policy is
# fetched from /.well-known/mta-sts.txt on mta-sts.<domain>, with pathType
# Exact (type: Exact in the HTTPRoute) because exactly one file lives there. A
# values key for a protocol-mandated path is a key that can only ever be set
# wrong — and "wrong" here is invisible from this side, see the limit below.
# Exact rather than a prefix also matters in its own right: a prefix on these
# hostnames would publish the mail server's whole management surface under
# them.
#
# ------------------------------------------------------------------------
# THE LIMIT, AND IT HAS TO BE SAID RATHER THAN SUGGESTED AWAY
# ------------------------------------------------------------------------
# THIS CHART REGISTERS A ROUTE. IT DOES NOT CREATE A DOMAIN. Read the first
# paragraph of this file again: since v0.16 the domains live in Stalwart's DATA
# STORE, and this chart never sees them. So whether the server answers for
# mta-sts.example.com depends on example.com existing in that data store, which
# no value here can arrange.
#
# An entry without its domain in the data store gives a 404 AT A CORRECTLY
# ROUTED ENDPOINT — every Kubernetes object green, the Ingress or route
# resolving perfectly, and nothing wrong on this side at all. And the other
# half of it is worse: MTA-STS failures are silent ON THE SENDER'S SIDE. A
# sending MTA that finds no policy does not report anything here; it simply
# falls back to delivering in the clear. So the thing this block exists to
# achieve can be absent with no symptom in this cluster whatsoever.
#
# That is the same genus as the OnePasswordItem limit this chart already
# documents: a promise the chart cannot keep, and therefore one it has to name.
# Check an entry from outside after adding it:
# curl https://mta-sts.<domain>/.well-known/mta-sts.txt
#
# ------------------------------------------------------------------------
# THE SUBDOMAIN TRAP, which is the one that will actually catch somebody
# ------------------------------------------------------------------------
# mta-sts.sub.example.com is ONE LABEL DEEPER than *.example.com, and a
# wildcard certificate matches exactly one label (RFC 6125). So a wildcard for
# the parent domain does NOT cover it, and the obvious assumption — "the
# wildcard already covers that" — is false.
#
# With a secret per entry that is solved, which is why the field exists. The
# trap is written down anyway because the symptom is a TLS error AT THE SENDING
# MTA: nothing in this cluster logs it, the endpoint looks healthy from here,
# and mail keeps flowing unencrypted. In Gateway mode the same trap moves to
# the Gateway's listener, where this chart cannot help at all — a wildcard
# listener for the parent domain will not terminate TLS for a deeper name.
#
# ------------------------------------------------------------------------
# WHY A SECRET PER ENTRY AND NOT certificate.extraDnsNames
# ------------------------------------------------------------------------
# The alternative was real and was rejected. This chart already has
# "Additional DNS names for the mail certificate" (certificate.extraDnsNames,
# further up this file), so the mta-sts.* names could have been folded into the
# mail certificate instead of carrying their own secrets.
#
# They are not, because that would hang the MAIL certificate on this list. The
# mail certificate is what serves STARTTLS on 25 and implicit TLS on 465/993;
# a bad entry here — a typo, a name whose DNS-01 challenge cannot be answered —
# would then fail the issuance of the certificate the mail ports depend on.
# A policy route that is only a nice-to-have would be able to take SMTP and
# IMAP with it. Separate secrets keep the blast radius at the entry.
#
# ------------------------------------------------------------------------
# NOT DONE BY THIS CHART, so it is not mistaken for done
# ------------------------------------------------------------------------
# - THE DNS RECORD. MTA-STS also needs a TXT record at _mta-sts.<domain>;
# without it no sending MTA ever looks for the policy. That is DNS, not
# Kubernetes, and nothing here creates it.
# - THE POLICY'S CONTENT. version/mode/max_age/mx come from Stalwart's data
# store, per domain. The running installation serves mode: testing, which
# means sending MTAs enforce nothing — also a data-store setting and not a
# chart question.
# - REMOVING AN EXISTING OBJECT FROM ANOTHER REPOSITORY. Where a route for
# one of these hosts already exists elsewhere (jk-cluster-config does serve
# mta-sts.bekemeyer-klein.de from a Fleet release of its own), the order is
# REMOVE THERE FIRST, then add the entry here. The other way round,
# ingress-nginx's admission webhook rejects the second object on the same
# host and path — which is the loud failure, but it is a failure.
mtaSts:
# Empty by default: no object in either mode, and no value becomes required.
#
# Entry form, and the two TLS fields apply ONLY IN INGRESS MODE:
#
# host REQUIRED. The policy hostname, i.e. mta-sts.<domain>.
# tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose:
# the plausible default (this release's own
# tls-<release>-<chart>-ingress) is the wrong certificate —
# it covers ingress.domain, not this host. Hosts that share
# one secret are collected into one tls: entry, so one
# certificate with several SANs is asked for once rather
# than once per host. Inert in Gateway mode.
# clusterIssuer OPTIONAL, and EMPTY MEANS NO ANNOTATION — i.e. "the
# certificate for this host comes from somewhere else, do
# not ask cert-manager for it". That is what keeps a secret
# owned by another release from being fought over, the same
# deliberate omission as the qBittorrent object in
# freesailarr and two of the four realm ingresses in Gitea
# #122. Entries sharing a secret must agree on it (the
# render aborts naming both), because cert-manager names the
# Certificate after the secret and the issuer is an
# object-level annotation. Inert in Gateway mode.
#
# The chart's own ingress.annotations are NOT copied onto these objects: they
# belong to the WebUI/JMAP host (on this installation they are its CORS
# configuration), and a policy file served to sending MTAs has no use for
# them.
#
# hosts:
# - host: mta-sts.example.com
# tlsSecretName: tls-mta-sts-example-com-ingress
# clusterIssuer: letsencrypt-issuer
# # A deeper name needs its own certificate — the parent's wildcard does
# # not reach it. See the subdomain trap above.
# - host: mta-sts.sub.example.com
# tlsSecretName: tls-mta-sts-sub-example-com-ingress
# clusterIssuer: letsencrypt-issuer
# # Certificate managed elsewhere: no issuer, so no annotation and no
# # Certificate requested by this release.
# - host: mta-sts.other.example
# tlsSecretName: tls-some-existing-wildcard-ingress
hosts: []
# ---------------------------------------------------------------------------
# Hardening — chart-wide, applied to every node group
# ---------------------------------------------------------------------------