Compare commits
8
Commits
bf7640104f
...
71b757bf9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b757bf9f
|
||
|
|
771c26500c
|
||
|
|
b4c9823e01
|
||
|
|
649417b428
|
||
|
|
5cdb2a6227
|
||
|
|
f530f58b1d
|
||
|
|
a411ec3c90
|
||
|
|
7a2eb29e9e
|
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user