docs(README): the ambient-mesh switch and the named exception it costs the render test
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 11s
Validate Helm Charts / Selector match (pull_request) Successful in 11s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 8s
Validate Helm Charts / Bump level (pull_request) Successful in 3s
Validate Helm Charts / YAML scalars (pull_request) Successful in 8s
Validate Helm Charts / NetworkPolicy (pull_request) Successful in 19s
Validate Helm Charts / Build apache-tika (pull_request) Successful in 4s
Validate Helm Charts / Trivy config apache-tika (pull_request) Successful in 10s
Validate Helm Charts / Build bentopdf (pull_request) Successful in 7s
Validate Helm Charts / Build bulwark (pull_request) Successful in 5s
Validate Helm Charts / Build garage (pull_request) Successful in 5s
Validate Helm Charts / Build gotenberg (pull_request) Successful in 7s
Validate Helm Charts / Build keycloak (pull_request) Successful in 6s
Validate Helm Charts / Build minecraft-router (pull_request) Successful in 12s
Validate Helm Charts / Build outline (pull_request) Successful in 5s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 5s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 5s
Validate Helm Charts / Build cyberchef (pull_request) Successful in 5s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 6s
Validate Helm Charts / Build homeassistant (pull_request) Successful in 8s
Validate Helm Charts / Build matrix-synapse (pull_request) Successful in 6s
Validate Helm Charts / Build minecraft-server (pull_request) Successful in 5s
Validate Helm Charts / Build patchmon (pull_request) Successful in 4s
Validate Helm Charts / Build rallly (pull_request) Successful in 7s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 4s
Validate Helm Charts / Build sure (pull_request) Successful in 3s
Validate Helm Charts / Build teamspeak-server (pull_request) Successful in 4s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 4s
Validate Helm Charts / Build ytdlp-web-player (pull_request) Successful in 6s
Validate Helm Charts / Build zipline (pull_request) Successful in 4s
Validate Helm Charts / Build samba (pull_request) Successful in 5s
Validate Helm Charts / Build satisfactory (pull_request) Successful in 4s
Validate Helm Charts / Build searxng (pull_request) Successful in 4s
Validate Helm Charts / Build stalwart (pull_request) Successful in 4s
Validate Helm Charts / Build template-chart (pull_request) Successful in 4s
Validate Helm Charts / Build urlaubsverwaltung (pull_request) Successful in 5s
Validate Helm Charts / Build valkey (pull_request) Successful in 4s
Validate Helm Charts / Trivy config bentopdf (pull_request) Successful in 11s
Validate Helm Charts / Trivy config bulwark (pull_request) Successful in 13s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 11s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 20s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 13s
Validate Helm Charts / Trivy config garage (pull_request) Successful in 10s
Validate Helm Charts / Trivy config gotenberg (pull_request) Successful in 11s
Validate Helm Charts / Trivy config homeassistant (pull_request) Successful in 13s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 11s
Validate Helm Charts / Trivy config matrix-synapse (pull_request) Successful in 20s
Validate Helm Charts / Trivy config minecraft-router (pull_request) Successful in 19s
Validate Helm Charts / Trivy config minecraft-server (pull_request) Successful in 13s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 12s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 13s
Validate Helm Charts / Trivy config patchmon (pull_request) Successful in 10s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 12s
Validate Helm Charts / Trivy config samba (pull_request) Successful in 23s
Validate Helm Charts / Trivy config satisfactory (pull_request) Successful in 13s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 15s
Validate Helm Charts / Trivy config rallly (pull_request) Successful in 11s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 15s
Validate Helm Charts / Trivy config sure (pull_request) Successful in 12s
Validate Helm Charts / Trivy config teamspeak-server (pull_request) Successful in 11s
Validate Helm Charts / Trivy config template-chart (pull_request) Successful in 11s
Validate Helm Charts / Trivy config urlaubsverwaltung (pull_request) Successful in 11s
Validate Helm Charts / Trivy config valkey (pull_request) Successful in 15s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 10s
Validate Helm Charts / Trivy config ytdlp-web-player (pull_request) Successful in 13s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 11s
Install-test / Install-test apache-tika (pull_request) Successful in 1m4s
Install-test / Install-test bentopdf (pull_request) Successful in 57s
Install-test / Install-test bulwark (pull_request) Successful in 1m6s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 1m31s
Install-test / Install-test cyberchef (pull_request) Successful in 45s
Install-test / Install-test freesailarr (pull_request) Successful in 3m0s
Install-test / Install-test garage (pull_request) Successful in 1m0s
Install-test / Install-test gotenberg (pull_request) Successful in 1m2s
Install-test / Install-test homeassistant (pull_request) Successful in 1m55s
Install-test / Install-test matrix-synapse (pull_request) Successful in 1m17s
Install-test / Install-test keycloak (pull_request) Successful in 2m8s
Install-test / Install-test minecraft-router (pull_request) Successful in 38s
Install-test / Install-test minecraft-server (pull_request) Successful in 1m25s
Install-test / Install-test outline (pull_request) Successful in 2m0s
Install-test / Install-test paperless-ngx (pull_request) Successful in 2m35s
Install-test / Install-test patchmon (pull_request) Successful in 1m2s
Install-test / Install-test rallly (pull_request) Successful in 1m15s
Install-test / Install-test rss-bridge (pull_request) Successful in 46s
Install-test / Install-test samba (pull_request) Successful in 1m6s
Install-test / Install-test sure (pull_request) Successful in 2m1s
Install-test / Install-test searxng (pull_request) Successful in 52s
Install-test / Install-test stalwart (pull_request) Successful in 1m0s
Install-test / Install-test teamspeak-server (pull_request) Successful in 1m14s
Install-test / Install-test urlaubsverwaltung (pull_request) Successful in 1m31s
Install-test / Install-test valkey (pull_request) Successful in 1m8s
Install-test / Install-test voucher-vault (pull_request) Successful in 1m35s
Install-test / Install-test ytdlp-web-player (pull_request) Successful in 50s
Install-test / Install-test zipline (pull_request) Successful in 1m35s
Validate Helm Charts / Install-test (pull_request) Successful in 38m26s

Gitea #143. Section 15 gets the switch itself - what it renders, why its two
halves are one value, and the two things it does not prove - plus the three
names that overlap as substrings (ingress, networkPolicy.ingress,
networkPolicy.ingressController), because a search for "ingress" matches all
three and one run during this work mistook the third for an inbound escape
hatch on that basis alone.

The render-test section gets assertion 7 as a table row, the re-measured
figures (30 charts, 333 assertions, ~25s), and the six fail paths that were
executed - including the two that are only visible to it: an ungated ipBlock,
which assertion 2 cannot see because it never looks at "from" peers, and a
value declared in the schema but missing from values.yaml, which a --set
render alone would accept.
This commit is contained in:
jklein committed 2026-10-19 13:16:35 +00:00
1 parent 8f50ec838b
commit fb275935c9
1 file changed
+26 -2
+26 -2
View File
@@ -231,6 +231,7 @@ Measured locally: **30 charts, 408 scalars verified, 0 failed**, no cluster. The
| no ingress rule allows every source by accident | `from: []` allows everything, identically to having no `from` at all, while reading in a diff and in `kubectl` output as a restriction — the one mistake in this class that makes a policy *weaker* while looking *stricter* | nobody |
| every port a **network** probe addresses is either unrestricted, or the chart offers `networkPolicy.probeSources` **and** says in `NOTES.txt` what a wrong CIDR costs | the kubelet probes from the IP of the worker node running the pod, which no chart can know. A restricted probe port without that address drops every probe: the pod never becomes `Ready`, the rollout times out, and the symptom reads as a broken application | the install-test, in production's CNI — **but not in CI**, see below |
| **cross-chart egress**: where a chart dials another chart *of this repository*, its egress rule must contain the **pod** ports behind that chart's Services (#136) | an egress rule naming the **Service** port. kube-proxy does the ClusterIP's destination NAT *before* the policy is evaluated at the pod's interface, so the policy sees `podIP:targetPort` and the rule matches nothing wherever `port` and `targetPort` differ | nobody: the symptom is a **timeout**, not `connection refused`, and no error mentions a NetworkPolicy — `paperless-ngx` 14.0.0 shipped it and cost an outage on 2026-10-15 |
| **the ambient-mesh switch**, on two renders per chart: with `networkPolicy.ingress.ambientMesh` off, neither `15008` nor `169.254.7.127/32` appears; with it on, `15008` is present and is still the **only** ingress port that is no `containerPort`, and a source-restricted rule carrying it also carries the `ipBlock` (#143) | the named exception decaying into the hole it was carved around. Without the off-half a chart could render `15008` unconditionally and nothing would notice; without the only-port half the exception becomes the free port key that was rejected, and every mistyped ingress port passes with the switch on | nobody — and in production as a pod unreachable in the mesh, or one whose every probe is dropped the first time `probeSources` is narrowed |
Egress ports are otherwise **deliberately not** held against the pod's `containerPort`s: an egress port is a port on the *destination*, and that comparison would fail every chart that reaches a database.
@@ -245,7 +246,9 @@ Four things to know before touching it:
- **Subchart documents are skipped by the comment assertion**, for a structural reason and not a lenient one: a dependency is fetched from the Helm registry rather than from the copy in this repo, so the policy `paperless-ngx` and `stalwart` bundle is whatever `valkey` version is *published*, and nothing in the parent chart can change its rendered comments. The subchart's own PR is where it is caught, because this test runs over `valkey` too.
- **Anti-vacuum guard**, as in the tests above: `ci/networkpolicy/<chart>.yaml` is a render-only overlay applied **last**, whose job is to render the **source-restricted probe rule** — a branch no chart's own defaults ever reach, and the one containing the expensive mistake (a `namespaceSelector` and a `podSelector` written as two list entries instead of one would be an OR, opening the port to every pod in `kube-system`). A chart that *has* an overlay and still renders no restricted **probe** rule fails. That counter is specifically the probe rule and not "any restricted rule", because `keycloak` restricts two rules in its own defaults and would have satisfied a looser guard while the branch went unrendered.
Measured locally: **30 charts, 210 assertions held, 0 failed**, no cluster, ~15 seconds. All twelve fail paths were exercised rather than assumed — and one earned its keep immediately: the `probeSources` check was a substring test, so a chart whose key had been **deleted** while the long comment above it still discussed the value passed it. It now looks for the key. The cross-chart assertion added two of those 210, and its own six fail paths were executed the same way: a required port removed from the rule, a wrong number in its place, a peer chart renamed away, the policy's name no longer following the `<release>-<chart>-<kind>` convention, the peers rendering no Service at all (which would make the requirement empty, and is treated as a failure because an empty requirement reads exactly like a satisfied one), and the gated rule made unconditional — which is the failure path of the *gate-off* half, since with the gate off and the rule properly gated the assertion must stay silent.
Measured locally: **30 charts, 333 assertions held, 0 failed**, no cluster, ~25 seconds (the ambient-mesh assertion added a second render per chart, and roughly 10 seconds with it). All twelve fail paths were exercised rather than assumed — and one earned its keep immediately: the `probeSources` check was a substring test, so a chart whose key had been **deleted** while the long comment above it still discussed the value passed it. It now looks for the key. The cross-chart assertion added two of those 210, and its own six fail paths were executed the same way: a required port removed from the rule, a wrong number in its place, a peer chart renamed away, the policy's name no longer following the `<release>-<chart>-<kind>` convention, the peers rendering no Service at all (which would make the requirement empty, and is treated as a failure because an empty requirement reads exactly like a satisfied one), and the gated rule made unconditional — which is the failure path of the *gate-off* half, since with the gate off and the rule properly gated the assertion must stay silent.
**The ambient-mesh assertion (#143) was built the same way, and six of its fail paths were executed**: a second, different port added under the exception (rejected — the exception covers exactly one number); `15008` rendered ungated, caught by assertion 2 in the main loop, which renders the switch at its default; the `ipBlock` rendered ungated, which **only** this assertion can catch, because assertion 2 never looks at `from` peers; the `ipBlock` half deleted while `15008` stayed, i.e. the half-set switch the design exists to make impossible, reachable because `ci/networkpolicy/template-chart.yaml` fills `probeSources`; the policy's name taken off the `<release>-<chart>-<kind>` convention; and the value present in the schema but absent from `values.yaml`, which a `--set` render alone would not have noticed. The name check earned its keep on the first run rather than in a rehearsal: it read the **directory** name, and `template-chart`'s chart is called `xxx`.
**What this test does not and cannot cover, named so the gap is not mistaken for coverage:** that traffic still flows. No ingress controller runs in the install-test cluster, so the path from Traefik to a pod is proven nowhere in this pipeline — and, measured on the PR for #5, **k3s exempts the kubelet's probe traffic from policy**, so a blocked probe path does *not* turn the install-test red either (`cyberchef` became `Ready` in 69s with its probe port restricted to an address no probe can come from). What the install-test *does* prove is enforcement of everything the application itself needs: with `outline`'s DNS egress rule removed, its pod never became `Ready` and the job failed after 5m59s. So the net under this convention is real, but it is an **egress** net — which raises the stakes for assertion 2, since a wrong ingress port is caught mechanically here or not at all.
@@ -693,7 +696,9 @@ Every port of a workload falls into one of four, and this is the part to get rig
#### Values
`networkPolicy.enabled`, `.allowedNamespaces`, `.probeSources`, `.ingressController`, `.egress.extraPorts` — and a chart ships **only the ones that reach a rule**. A chart may add a **named** egress switch where a port depends on something the values cannot see: `paperless-ngx`' `.egress.mailImport` opens IMAPS `993`, off by default, because its mail accounts are rows in its own database and no value says whether a release fetches mail. The bar for one of those: it names what it opens, and it is **not** a second switch for a port some *other* value already decides — that is why the Tika/Gotenberg rule stayed gated on `paperless.files.tikaGotenberg.enabled` and got no policy switch of its own. Two values that can drift apart re-arm the outage the rule was fixed for. A value that silently does nothing is worse than one that is absent, because it reads as a control: `samba` and `satisfactory` carry `enabled` alone (every inbound rule is open to any source), and `teamspeak-server` has `allowedNamespaces` but no `probeSources`.
`networkPolicy.enabled`, `.allowedNamespaces`, `.probeSources`, `.ingressController`, `.ingress.ambientMesh`, `.egress.extraPorts` — and a chart ships **only the ones that reach a rule**, with `.enabled` and `.ingress.ambientMesh` the two every chart has.
**Three of those names overlap as substrings and are three different things**, which is worth stating because a search for `ingress` matches all of them: top-level `ingress` is the Ingress **object** a chart renders, `networkPolicy.ingress` is the policy's **inbound side**, and `networkPolicy.ingressController` is the **source selector** for the controller's pods — a *sibling* of `networkPolicy.ingress` and no part of it. The 21 charts carrying `ingressController` had **no** inbound escape hatch before #143, and one search run during that work reported them as having one on the strength of the substring alone. A chart may add a **named** egress switch where a port depends on something the values cannot see: `paperless-ngx`' `.egress.mailImport` opens IMAPS `993`, off by default, because its mail accounts are rows in its own database and no value says whether a release fetches mail. The bar for one of those: it names what it opens, and it is **not** a second switch for a port some *other* value already decides — that is why the Tika/Gotenberg rule stayed gated on `paperless.files.tikaGotenberg.enabled` and got no policy switch of its own. Two values that can drift apart re-arm the outage the rule was fixed for. A value that silently does nothing is worse than one that is absent, because it reads as a control: `samba` and `satisfactory` carry `enabled` alone (every inbound rule is open to any source), and `teamspeak-server` has `allowedNamespaces` but no `probeSources`.
**No chart ships `allowedNamespaces` pre-filled, ever.** A chart that named `mc-router`, or `paperless-jk`, or `stalwart` in its defaults would be baking in an assumption about *this* cluster's namespace layout — which is exactly the kind of claim a chart cannot keep true. So the default is the empty list and the chart is **restrictive**; filling it is the **deployment adaptation**, made where the namespace layout is actually visible. That is why a sweep like this produces a list of release-side one-liners: those are the expected procedure, not a defect list, and the breaking-change major on each chart is what announces them.
@@ -701,6 +706,25 @@ Every port of a workload falls into one of four, and this is the part to get rig
`ingressController` is a separate value with two keys rather than a line in `allowedNamespaces` for one reason: **its two selectors render as one `from` entry, which is an AND** — those pods in that namespace. Written as two list entries they would be an OR, and putting `kube-system` into `allowedNamespaces` instead would open the port to every pod in the cluster's own namespace. Its default is **measured, not assumed**: `ingress-nginx` is gone from this cluster, the only `IngressClass` is `traefik`, and the controller is the DaemonSet `rke2-traefik` in `kube-system` — which also serves the Gateway API here, so one source covers the Ingress and the HTTPRoute path alike. `ingress.className` says nothing about it: a class names an implementation, not the pods the traffic comes from.
#### The ambient-mesh switch, and the one named exception in the render test (#143)
**`networkPolicy.ingress.ambientMesh`, in all 30 charts, default `false`.** Istio runs on this cluster in ambient mode, and Istio's own documentation is explicit that *"ambient does not and will never bypass Kubernetes `NetworkPolicy` enforcement"* — so until this value existed, **no chart here could put a pod into the mesh at all**. Additive, unchanged default, unchanged render: **minor** per chart, and the render was diffed against the previous release per chart rather than asserted.
**It renders two things, and that they are one value is the whole design.**
1. **`15008/TCP` inbound** — ztunnel's HBONE tunnel, where every inbound mesh connection arrives.
2. **`169.254.7.127/32` as an additional peer**, but only where the rule restricts sources at all. Inside the mesh the kubelet's probes are **redirected** and no longer arrive from the worker node's address.
Point 2 is the one nobody thinks of, which is why the two halves cannot be had separately. An operator who adds `15008` to a port list by hand writes a policy that passes review — and the first time `probeSources` is narrowed, every probe is dropped, the pod never becomes `Ready`, and the symptom reads as a broken application rather than as a policy. That is the probe-path trap of this section one value further along. **A switch whose halves can only be had together cannot be set halfway**, so this is a named intention and not a port list. Charts with no `probeSources` at all render the port half only: their probe rule is never source-restricted, and an `ipBlock` for a redirected probe would match nothing while making the rule read as if it did something.
**The interesting part is the CI, because the obvious implementation was the worst one.** Assertion 2 of `ci/run-networkpolicy-test.sh` holds every ingress port against the selected pod spec's `containerPort`s, and `15008` is deliberately none — ztunnel terminates it, not the application. A free `ingress.extraPorts` key would have had to exempt *operator-supplied ports* from that assertion, which opens it for **all 30 charts**: every mistyped ingress port, the NodePort mistake included, would then pass. So the assertion keeps its edge and gets **one named exception for one port number, gated on one value** — and it is not granted in the main loop at all, which renders every chart with the value at its default, so `15008` stays an ordinary failure there.
**An exception without its own check is the hole it was carved around**, so assertion 7 holds it to five things on **two renders of every chart**: with the switch off, neither `15008` nor the `ipBlock` is present; with it on, `15008` is present, `15008` is still the **only** ingress port that is not a `containerPort`, and wherever the rule carrying it restricts sources it carries the `ipBlock` too. The policy is found by the `<release>-<chart>-` name convention, because an exception must not be granted to an object the test could not identify. It lives in the existing script rather than a new one for the reason #136's cross-chart assertion does, only more so: **the exception weakens assertion 2, so it belongs beside it** — a separate script can be green while the exception it is paired with is wrong, and nothing would connect the two.
**Two things this does not prove, and they are not footnotes.** That `15008` *alone* suffices comes from Istio's documentation; what is measured on this cluster is only which listeners ztunnel opens. And the probe-source half reaches no release, because `probeSources` is empty in every chart (`stalwart` sets it to `[]` explicitly) — CI proves its render through the overlays in `ci/networkpolicy`, not its effect. Both become provable at the first namespace to join the mesh, and **none carries `istio.io/dataplane-mode`** (measured 2026-10-19, 0 of 97). Whether ztunnel needs a source selector of its own is open for the same reason — but it is *not* the unknowable case it looked like: ztunnel runs with its own pod IPs in `istio-system` (no `hostNetwork` on the DaemonSet, Istio 1.30.6), so a peer for it would be a `namespaceSelector` plus `podSelector`, not a node-IP `ipBlock`.
**Consumer side:** switching this on for a release is a value key *and* a chart version in **one** PR in `jk-cluster-config`, because Fleet does not retry a failed apply — and `networkPolicy` carries `additionalProperties: false` in all 30 charts, so an unknown key does not get ignored, it fails the render.
#### What these policies do not do — and it belongs in every chart, not just here
- **Egress is coarse: by port, never by destination.** A `NetworkPolicy` matches `ipBlock` CIDRs and pod/namespace selectors and **never hostnames**, so "only to the database host" is not expressible at all. This cluster's CNI is Canal (Flannel plus Calico for policy) and has no FQDN rule; Calico's `GlobalNetworkSet` could express one but would bind every chart to Calico — the same argument that kept implementation-specific objects out of the Gateway-API sweep (#114) — so it is **not used**. This is a limit of the object, and each chart names what it costs *that* chart.