paperless-ngx, CI/CD, style guide, lessons: an egress rule names the pod port

Gitea #136. The paperless-ngx page said an egress rule names the port on the
destination as the client dials it, and that paperless dials the Tika and
Gotenberg Services on 80. The chart acted on it and the release cut Paperless
off from both services on 2026-10-15.

kube-proxy does a ClusterIP Service's destination NAT before the egress policy
is evaluated at the pod's interface, so the policy sees podIP:targetPort - the
rule names the pod port, 9998 and 3000 here. The old sentence is quoted as
refuted rather than removed, together with the symptom that identifies it: a
timeout, not connection refused.

- paperless-ngx: the rule's ports and the quick repair for 14.0.0, the new
  networkPolicy.egress.mailImport (993, off), extraPorts as pod ports inside the
  cluster, and the install-test's Tika gate being off, so a green install-test is
  not cover for the corrected path.
- CI-CD: the cross-chart assertion as a fifth row, why it needs two charts, the
  gate-on/gate-off pair, and 210 assertions over 30 charts.
- Chart-Style-Guide: pod ports also in egress, and the bar for a named egress
  switch - no second switch for a port another value already decides.
- Lessons-Learned: the lesson, plus how the wrong sentence passed every check
  while the correct mechanism stood two days older in the style guide.
jklein committed 2026-10-15 17:51:52 +00:00
1 parent f700f74a4c
commit 42e5f416f0
4 files changed
+83 -8

No files matched your search

+7 -2
@@ -221,8 +221,11 @@ The sixth job that ignores the changed-chart matrix and the fifth that renders e
| every **ingress** port is a `containerPort` of the selected pods | a NodePort number where a pod port belongs. The policy is evaluated at the pod's interface, *after* kube-proxy translated the port, so `30025` matches nothing while looking strict — and being the only rule for that port, it blocks every inbound connection instead | nobody in CI (see below), and in production as a service that went mute |
| no ingress rule allows every source by accident | `from: []` allows everything, identically to no `from` at all, while reading in a diff and in `kubectl` output as a restriction — the one mistake here that makes a policy *weaker* while looking *stricter* | nobody |
| every port a **network** probe addresses is either unrestricted, or the chart offers `probeSources` **and** says in `NOTES.txt` what a wrong CIDR costs | the kubelet probes from the worker node's IP, which no chart can know. A restricted probe port without it drops every probe: the pod never becomes `Ready` and the symptom reads as a broken application | the install-test, under production's CNI — **but not in CI**, see below |
| **cross-chart egress** (#136): where a chart dials another chart *of this repository*, its egress rule must contain the **pod** ports behind that chart's Services | 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 names a NetworkPolicy. `paperless-ngx` 14.0.0 shipped it and lost Tika and Gotenberg in production on 2026-10-15 |
Egress ports are **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.
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.
**The fifth row is the exception, and the only assertion here that needs two charts.** For a database, a cache or the web the destination is in no render this repository has — but it *is* in a render when one chart dials the Service of another, and `paperless-ngx` → `apache-tika`, `gotenberg` is the single place where that happens (checked for #136: the three other policies naming `port: 80` aim at the **internet**, where there is no DNAT and 80 is the real port). The pairs are an explicit table in the script (`EGRESS_PEER_ROWS`), not a discovery, because no render can know which Service another chart dials — the address is a value or a 1Password field — and a row naming a chart that no longer exists **fails** rather than being skipped. Each row runs against **two** renders of the consumer: with the feature gate **on** the containment must hold, resolved `Service port -> targetPort -> containerPort` so the evidence line prints the whole DNAT chain (`port 80 -> targetPort 'tika-port' -> pod port 9998`); with the gate **off** the requirement must not apply *and* those ports must be gone, so a gated rule cannot quietly become one that permits what the release cannot use. It lives in this script rather than a sixth one because the job already renders every chart on every PR, Helm is already the parser, and a second script would duplicate all of that to add one comparison.
- **Two guards beyond the four, both earned.** A chart that renders **no** policy fails — that is how this issue decayed in the first place, since `garage` and `keycloak` were created after it was filed and arrived without one. And a **`#` comment inside a rendered policy** fails, because a `#` comment is part of the *object*: it ships to the cluster, appears in `helm get manifest`, and lands in every release diff. Both precedent charts failed that check the first time it ran, which is what it is for.
- **Assertion 3 is stricter than it reads.** A *missing* `from` is the documented open form and both precedent charts use it on purpose, so an outright ban was not available. It is accepted only where the render itself justifies it: every port of the rule is probed, or is published by a `NodePort` Service in the same render. Anything else is an allow-all nobody chose.
@@ -230,7 +233,9 @@ Egress ports are **deliberately not** held against the pod's `containerPort`s: a
- **Anti-vacuum guard.** `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 reach, and the one holding the expensive mistake: a `namespaceSelector` and a `podSelector` as two list entries instead of one would be an OR, opening the port to every pod in `kube-system`. A chart with an overlay that renders no restricted **probe** rule fails. The counter is specifically the probe rule, because `keycloak` restricts two rules in its own defaults and would have satisfied a looser guard while the branch went unrendered.
- **Helm is the YAML parser**, through `ci/yaml2json`, for the same reason as the render tests above.
Measured: **30 charts, 206 assertions held, 0 failed**, no cluster, a few 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 comment above it still discussed the value passed. It now looks for the key.
Measured: **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 comment above it still discussed the value passed. It now looks for the key.
The cross-chart assertion is 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, the peer chart renamed away, the policy's name no longer following the `<release>-<chart>-<kind>` convention, the peers rendering no Service at all (an empty requirement reads exactly like a satisfied one, so it fails), and the gated rule made **unconditional** — which is the fail path of the *gate-off* half, since with the gate off and the rule properly gated the assertion must stay silent.
**What no part of this pipeline covers, and it was measured rather than assumed.** On the PR for #5 two experiments ran and were then reverted:
+3
@@ -362,6 +362,8 @@ Every port falls into one of four, and the class decides the rule:
`networkPolicy.enabled`, `.allowedNamespaces`, `.probeSources`, `.ingressController`, `.egress.extraPorts`. A chart ships **only the ones that reach a rule**: 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; `teamspeak-server` has `allowedNamespaces` but no `probeSources`.
**A chart may add a *named* egress switch where a port depends on something the values cannot see.** [`paperless-ngx`](paperless-ngx#networkpolicy)'s `egress.mailImport` opens IMAPS `993`, off by default, because its mail accounts are rows in its own database and nothing in the values says whether a release fetches mail. Two conditions for one of those: it **names what it opens** (so `mailImport` covers `993` and not also `143`, `110`, `995` — those stay in `extraPorts`), and it is **not a second switch for a port another value already decides**. That second half is Gitea #136's doing: the Tika/Gotenberg rule was deliberately left gated on the **feature** value `paperless.files.tikaGotenberg.enabled` and given no policy switch of its own, because two values that can drift apart re-arm the outage the rule was being fixed for — whoever enables the feature and forgets the policy switch lands in the same timeout, with the same error that does not mention a policy.
**No chart ships `allowedNamespaces` pre-filled, ever.** A chart naming `mc-router`, `paperless-jk` or `stalwart` in its defaults would bake in an assumption about *this* cluster's namespace layout — a 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 Gitea #5 produces a list of release-side one-liners: they are the **expected procedure**, not a defect list, and the breaking-change major on each chart is what announces them. `ingressController` is the single exception, because it names a cluster *component* rather than a tenant — and it is still overridable.
`ingressController` is its own two-key value 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. As two list entries they would be an OR, and `kube-system` in `allowedNamespaces` would open the port to every pod in the cluster's own namespace. Its default is **measured, not assumed** (2026-10-14): `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.
@@ -381,6 +383,7 @@ Every port falls into one of four, and the class decides the rule:
### Mechanics
- **Pod ports, never NodePorts.** A policy is evaluated at the pod's interface, *after* kube-proxy translated the port. `30025` where the pod listens on `25` matches nothing: accepted, strict-looking, and blocking every inbound connection instead.
- **And pod ports, never Service ports — in `egress` too** (Gitea #136, paid for with an outage). The same destination NAT runs on the way **out**: kube-proxy rewrites a ClusterIP's address *and port* **before** the egress policy is evaluated, so the policy sees `podIP:targetPort` and never `clusterIP:port`. An egress rule therefore names the **pod** port of its destination. A Service port matches only by coincidence — wherever `port` and `targetPort` are the same number, which is true of `5432`, `6379`, `993` and `443`, and is why the mistake stayed invisible until a chart dialled a Service forwarding `80` to `9998`. For a destination **outside** the cluster there is no NAT in the path and the port dialled *is* the port to name: the deciding question is the DNAT, not the direction. **The tell is the symptom** — a dropped packet runs into a **timeout**, a missing listener answers `connection refused`, so a timeout to a service that demonstrably exists is a policy question, and no Kubernetes error will say so. Where the destination is another chart *of this repository*, `ci/run-networkpolicy-test.sh` asserts it; everywhere else it is on whoever writes the rule.
- **Protocol as well as number.** `teamspeak-server`'s voice port is UDP and `satisfactory`'s game port is both — a TCP-only rule on either is accepted, looks complete, and drops the traffic that arrives.
- **Never `from: []`.** It allows everything, identically to having no key, while reading as a restriction. Write no `from` where open is meant and say so in a template comment.
- **Reasoning in Go template comments, never `#`.** A `#` comment is part of the rendered *object*: it ships to the cluster and lands in every release diff. And such a comment must not spell out its own closing delimiter — the parser ends it early and the rest is emitted as manifest content, failing the render with `did not find expected node content` while pointing at the template rather than at the sentence.
+36
@@ -884,3 +884,39 @@ The #5 render test fails a chart whose rendered `NetworkPolicy` contains a `#` c
So the assertion now skips documents whose `# Source:` path contains `/charts/`. Not out of leniency: holding the parent responsible would have made the test **unfixable within the PR** and would fail a chart for a file its own diff does not contain. The subchart's own PR is where it is caught, because the job runs over every chart including `valkey`.
**The transferable part:** a check that runs over a rendered tree is running over code from more than one release. Before making one a gate, ask which failures the author of *this* diff can actually fix — and scope it to those. The rest belongs to whoever owns the other artifact.
## The rule that named the Service port, and the sentence that stopped anyone checking
An **egress** rule names the **pod** port of its destination, never the Service port, because the destination NAT happens **before** the policy is evaluated. And the symptom of getting it wrong is a **timeout**, not `connection refused`.
`paperless-ngx` 14.0.0 allowed outbound `80` for Tika and Gotenberg — the port those two charts' Services listen on — and said why, in the template, in `values.yaml` and on its wiki page:
> an EGRESS rule names the port on the DESTINATION as the client dials it, and the client dials the Service.
`kube-proxy` rewrites a ClusterIP's address *and port* before the packet reaches the pod's network interface, which is where the policy is evaluated. The policy sees `podIP:targetPort`. Both Services forward `80` to `9998` and `3000`, so the rule matched nothing, and the release cut Paperless off from both services on 2026-10-15: mail import, EML, HTML and Office documents stopped, **plain PDFs and images kept working**, every pod stayed `Ready`, and the error was `httpx.ConnectTimeout` naming the Tika URL. Nothing mentioned a NetworkPolicy.
**The error type is the diagnostic, and it is the cheapest thing to carry away:** a missing listener **refuses**, a dropped packet **runs into the timeout**. A timeout to a service that demonstrably exists is a policy question. Nothing in Kubernetes will say so, and the application's own error — correctly — blames the application's own dependency.
Why only this one rule broke, and why that made it so quiet: `5432`, `6379`, `993` and `443` all have Service port and pod port **the same number**, so the wrong mental model produces the right rule everywhere else. Checked across the repository: 58 Services in the cluster map a different port, and the charts' egress rules named exactly these two. Three other charts name `port: 80` in a policy and are **correct** — their destination is the internet, where there is no DNAT and 80 is the real port.
### How the wrong sentence got in, which is the part worth the entry
The information was already written down, in this repository, **two days earlier** and by the same session. The style guide's mechanics for #5 say:
> A policy applies to traffic that came through a Service… a Service is only destination NAT and the policy is evaluated at the **pod's** interface, so a ClusterIP exempts nothing.
That is the correct mechanism. It was used to argue the **ingress** direction (why a `podSelector` can still match the Traefik pod) and never carried over to the **egress** direction, where the same destination NAT decides which *port* to name. The premise was present, the conclusion was missing, and the sentence written in its place was fluent, specific and plausible — which is precisely why no review stopped on it. It survived a pull request, a render test, an install-test and three documents.
**So: a confident explanation in a comment is not evidence, and it costs more than no comment at all** — it reads as the work of checking, so the checking does not happen. Where a rule rests on a mechanism, the comment should name that mechanism in a form a measurement could **contradict** ("the policy sees `podIP:targetPort`"), not in a form that merely sounds like a reason ("the client dials the Service"). And when a mechanism is established for one direction, ask what it implies for the other **in the same sitting** — that is the moment the knowledge is actually present.
### What now catches it, and the limit of that
The #5 assertions hold a policy's ports against the pod spec it **selects** — right for ingress, meaningless for egress, where the destination is in another chart's render or in no render at all. Within one chart this mistake is **not** catchable, and that is not a defect of the check.
`ci/run-networkpolicy-test.sh` now also renders `apache-tika` and `gotenberg` alongside `paperless-ngx` and requires the egress rule to contain their `containerPort`s, resolved `Service port -> targetPort -> containerPort` so the output prints the DNAT chain rather than two bare numbers. It is checked with the feature gate **on** *and* **off**; the pairs are an explicit table, because no render can know which Service another chart dials (the address is a value or a vault field); and a row naming a chart that no longer exists **fails** rather than being skipped.
**The limit, named so it is not mistaken for coverage:** this works only because both destinations are charts of *this* repository, and `paperless-ngx` is the only place where that is true. For a database, a cache, an OIDC issuer or anything else outside the repo, nothing can check the number — the only defence there is knowing that the DNAT, and not the direction, decides it.
### And one decision that went the other way on purpose
The obvious-looking fix was a second value, `networkPolicy.egress.tikaGotenberg`. It was **rejected**. A policy switch that can drift from the feature switch re-arms the identical outage: whoever enables Tika and forgets the policy value lands in the same timeout with the same unhelpful error. The rule stays gated on the feature itself. **A guard that can be out of step with the thing it guards is not a guard** — and the gap between two values is invisible in exactly the way the first bug was.
+37 -6
@@ -260,7 +260,9 @@ The first draft of that check was a `fail()` in `_helpers.tpl` called from a ren
### `networkPolicy`
Inbound **8000** only. Outbound **DNS, TCP 5432** (Postgres), **6379** (Redis/Valkey), **443**, and — only while `paperless.files.tikaGotenberg.enabled` is true — **80** for the Tika and Gotenberg **Services**.
Inbound **8000** only. Outbound **DNS, TCP 5432** (Postgres), **6379** (Redis/Valkey), **443**, and — only while `paperless.files.tikaGotenberg.enabled` is true — **9998, 3000 and 80** for Tika and Gotenberg. With `networkPolicy.egress.mailImport` on, also **993** (IMAPS).
**Chart versions up to and including `14.0.0+up3.3.1` named only `80` in that rule, and it never matched** — see [Why that rule names 9998 and 3000, not the Services' 80](#why-that-rule-names-9998-and-3000-not-the-services-80). On such a release with Tika enabled the repair is `networkPolicy.egress.extraPorts: [9998, 3000]`; the fix is chart `14.1.0+up3.3.1`.
| Value | Type | Default | Meaning |
|---|---|---|---|
@@ -268,7 +270,8 @@ Inbound **8000** only. Outbound **DNS, TCP 5432** (Postgres), **6379** (Redis/Va
| `networkPolicy.allowedNamespaces` | list | `[]` | Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the `kubernetes.io/metadata.name` label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to **every** pod in it. |
| `networkPolicy.probeSources` | list | `[]` | CIDRs allowed to reach the probed port. **Empty means open, not blocked** — the rule is then rendered with no `from` at all. Probes come from the IP of the **worker node running the pod**, which the chart cannot know; a list that misses it drops every probe and the pod never becomes `Ready`. |
| `networkPolicy.ingressController` | object | `kube-system` / `app.kubernetes.io/name: rke2-traefik` | The controller allowed to reach the routed port once `probeSources` restricts it. The two keys render as **one** `from` entry, i.e. an AND — which is why this is not a line in `allowedNamespaces`, where it would open the port to every pod in `kube-system`. Default measured on the cluster, not assumed. |
| `networkPolicy.egress.extraPorts` | list | `[]` | Additional outbound **TCP** ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
| `networkPolicy.egress.mailImport` | bool | `false` | Opens outbound **993** (IMAPS) for paperless' mail import. A value and not a derivation, because the mail accounts are **rows in the database**, entered in the web UI — nothing in the values says whether a release fetches mail. **993 only**: plain IMAP (`143`) and POP3 (`110`, `995`) are deliberately not covered and go into `extraPorts`. Unlike the Tika rule this *is* the real destination port, because a mail server outside the cluster is reached with no Service and so no destination NAT in the path. |
| `networkPolicy.egress.extraPorts` | list | `[]` | Additional outbound **TCP** ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. **For a destination inside the cluster these are pod ports**, not Service ports — see the note below. |
## Operating notes
@@ -508,13 +511,37 @@ Nothing about OIDC is exercised before someone actually signs in, so a wrong dis
The same mechanism is the reason `httpRoute.hostnames` must not disagree with `ingress.domain`: a route published under a name Django does not trust produces exactly this picture — pages load, logins fail — except that nothing about it looks like a port-forward. See [Routing](#routing-ingress-or-gateway-api).
### Why that rule says 80 and not 9998/3000
### Why that rule names 9998 and 3000, not the Services' 80
An **egress** rule names the port on the **destination as the client dials it**, and paperless dials those charts' *Services*, which listen on 80 and forward to the containers' own 9998 and 3000. Addressing a container port directly instead needs that port in `networkPolicy.egress.extraPorts`.
**This page said the opposite until chart `14.1.0+up3.3.1`, and the chart acted on it. It cost an outage on 2026-10-15** (Gitea #136). The sentence that stood here, kept because it reads perfectly and that is exactly why nobody checked it:
### Mail fetching is not permitted
> An **egress** rule names the port on the **destination as the client dials it**, and paperless dials those charts' *Services*, which listen on 80 and forward to the containers' own 9998 and 3000.
Paperless' mail accounts are **rows in its database**, configured in the web UI, so this chart cannot see them. A release that fetches mail needs IMAP (993, or 143) in `networkPolicy.egress.extraPorts` — otherwise a mail rule silently stops importing, with a healthy pod behind it.
`kube-proxy` does a ClusterIP Service's destination NAT **before** the egress policy is evaluated at the pod's network interface. The policy therefore sees `podIP:targetPort` and never `clusterIP:port` — so an egress rule names the **pod** port, and these two Services forward `80` to `9998` and `3000`:
```
cluster-tika-apache-tika-service port 80 -> targetPort tika-port = 9998
cluster-gotenberg-gotenberg-service port 80 -> targetPort gotenberg-port = 3000
```
What it looked like after rolling out `14.0.0+up3.3.1`: mail import, EML, HTML and Office documents stopped, **plain PDFs and images kept working**, every pod stayed `Ready`, and the error was
```
httpx.ConnectTimeout: timed out
documents.parsers.ParseError: Could not parse content with tika server at http://…: timed out
```
**The error type is the tell, and it is the part worth carrying to the next chart: a timeout, not `connection refused`.** A missing service refuses; a dropped packet runs into the timeout. Nothing in any message points at a NetworkPolicy. The reason `5432`, `6379` and `443` were unaffected is only that their Service port and pod port are the same number.
The rule now names `9998`, `3000` **and** `80` — the `80` costs nothing and covers a foreign Tika that really does listen on 80, where 80 *is* the pod port. A Tika or Gotenberg on some other port still needs it in `networkPolicy.egress.extraPorts`, as a **pod** port.
**It has no policy switch of its own, deliberately.** The rule stays gated on the *feature* value `paperless.files.tikaGotenberg.enabled`. A second switch that can drift from the feature re-arms this exact outage: whoever enables Tika and forgets the policy switch is back here, with the same message that does not mention the policy. [CI/CD](CI-CD#8-networkpolicy-every-chart-every-pr) now renders `apache-tika` and `gotenberg` alongside this chart and asserts that this rule contains their `containerPort`s — the one assertion a single-chart render cannot make, and the reason this was not caught the first time.
### Mail fetching is not permitted by default
Paperless' mail accounts are **rows in its database**, configured in the web UI, so this chart cannot see them and cannot derive the rule. A release that fetches mail sets **`networkPolicy.egress.mailImport: true`** for IMAPS (993); plain IMAP (`143`) and POP3 go into `networkPolicy.egress.extraPorts`. Left off on a release that does fetch mail, a mail rule silently stops importing, with a healthy pod behind it.
`993` is the **real** destination port, unlike the Tika rule above: a mail server outside the cluster is reached with no Service and so no destination NAT in the path. What decides whether an egress rule needs the Service port or the pod port is that NAT — not the direction, and not the protocol.
### Two pods of this release are not covered by this object
@@ -527,6 +554,8 @@ A `NetworkPolicy` matches CIDRs and selectors and **never hostnames**, and this
Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the **conventional** ports, and a dependency listening anywhere else is **blocked**. Put the real port in `networkPolicy.egress.extraPorts`.
**And for a dependency inside the cluster, "the real port" is the port its *pod* listens on, not the port of its Service** — see the Tika note above. Reading it off a `kubectl get svc` column is the mistake that caused #136; the number to use is the Service's `targetPort`, resolved to the `containerPort` behind it. Outside the cluster there is no translation and the port dialled is simply the port.
### The probed port is open inside the cluster by default
@@ -555,6 +584,8 @@ Measured for this cluster: the CNI is **Canal** (Flannel for the network, Calico
`ci/paperless-ngx/` holds `test-values.yaml` (op:// refs against the stub CRD, `paperless.ci.local`, and a deliberately childless `barcodes:` key as the regression guard for #61), `fixtures.yaml` (throwaway Postgres, the dummy secrets, a no-provisioner `longhorn` StorageClass with pre-bound hostPath PVs, and a node-setup Job that pre-creates the exact subPath directories world-writable because hostPath volumes ignore `fsGroup`) and `settings.env` (`INSTALL_TIMEOUT=600` for the first-start migrations). `smbConnection` and `oidc` stay off in CI. The install-test runs on every PR that touches the chart — see [CI/CD](CI-CD).
**`paperless.files.tikaGotenberg.enabled` is off in that fixture too** (the chart default), so the install-test installs paperless **without** Tika and Gotenberg and never carries the egress rule of #136 at all. That path is therefore **not** proven end-to-end in CI: what CI proves is the render and the cross-chart assertion, and enforcement of the corrected rule was proven in the cluster instead. Worth knowing before reading a green install-test as cover for it.
**The subchart is ON in CI, and the throwaway Redis Deployment is gone** — this is the **first real installation of the valkey-subchart path** anywhere in this repository. [stalwart](stalwart) added the same dependency first but covered it by a local render only, deliberately: its in-memory store is a database object, so a Valkey in that namespace would have been reachable and unused and the test would have looked as though it covered clustering. Here it is the opposite — paperless reads `PAPERLESS_REDIS` from the vault item, so pointing that url at `ci-valkey-service` makes the bundled Valkey the broker the release actually uses. What the run proves beyond the previous one: the subchart's StatefulSet becomes Ready inside a parent release (so its init container found the `requirepass ` line and its three authenticated `valkey-cli` probes answered `PONG`), its PVC path works under a parent release name, and the names really are `<release>-valkey-<kind>`. A wrong Service name or a password mismatch between the item's two fields is a red run rather than a quietly idle Valkey.
**Two render-only overlays switch `smbConnection` on, and they are the only thing in the repository that renders the consume CronJob.** The install-test fixture keeps SMB off (no SMB CSI driver on the runner), so until Gitea #11 that object existed in the chart and in no render — which is exactly how an unquoted schedule shipped for months. `ci/yaml-scalar/paperless-ngx.yaml` renders it with a **star-leading schedule** and with hostile `env` values for `ci/run-yaml-scalar-render-test.sh`; `ci/selector-match/paperless-ngx.yaml` renders it plainly so `ci/run-selector-match-test.sh` can hold the mover's required `podAffinity` against the StatefulSet's pod labels. They are kept apart on purpose: folding them would make each test's failure depend on the other's fixture. Neither reaches the symptom of Gitea #101 — k3d has no Longhorn — so the placement fix is covered at the render level and the mount failure is not reproducible in CI.