1 Commits
Author SHA1 Message Date
jlab-renovate-bot 9251872947 Update dependency kubernetes/kubernetes to v1.37.1
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 17s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 15s
2026-10-13 11:35:36 +00:00
170 changed files with 194 additions and 13774 deletions

No files matched your search

+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
# invisible there. The value mirrors K3S_IMAGE above (v1.35.5), which is
# the cluster this client talks to.
# renovate: datasource=github-releases depName=kubernetes/kubernetes
KUBECTL_VERSION: v1.35.9
KUBECTL_VERSION: v1.37.1
# The same expression as runs-on above, written a second time because the
# env context is not available in runs-on - so it cannot be the other way
# round, and reading the label back off the runner is not possible either
-122
View File
@@ -237,128 +237,6 @@ jobs:
- name: "Check every rendered HTTPRoute against the rendered Services"
run: ci/run-httproute-match-test.sh
bump-level:
name: "Bump level"
runs-on: ubuntu-latest
# The fourth job that ignores the changed-chart matrix, and the only one
# that touches no chart at all: it tests ci/renovate-bump-chart.sh, the
# postUpgradeTasks hook that computes a chart's `version:` after Renovate
# moved an app version (Gitea #128).
#
# Why it is a job rather than a review: since #128 the hook takes the bump
# level from Renovate's update type, so an app minor becomes a chart minor.
# That makes it deciding logic, and its failure mode is silence - a wrong
# level is a VALID version string, so the PR goes green, the chart
# publishes, and the only symptom is a number that understates what it
# ships. Nothing else in this pipeline reads that number.
#
# It needs no Helm, no cluster and no Python: each case is a throwaway
# `git init` under mktemp with one commit plus a dirty Chart.yaml - the
# state the hook really runs in, since it reads the base commit - and the
# assertion is one `version:` line read back with sed. About a second for
# all 49 cases, and it depends on no other job.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Check every bump level of the version-bump hook"
run: ci/run-bump-level-test.sh
yaml-scalar:
name: "YAML scalars"
runs-on: ubuntu-latest
# The fifth job that ignores the changed-chart matrix, and the fourth that
# runs over *every* chart (Gitea #11), for the same reason as the three
# render tests above: the unquoted
# form arrives by copy into a chart whose own diff nobody reads for it, and
# it is in seven charts right now.
#
# What it asserts: every CronJob schedule, every env[].value and every label
# and annotation value a chart renders is a STRING, which is what Kubernetes
# types all three as. A value rendered without | quote fails in one of two
# ways and the quiet one is the dangerous one: the render aborts with a YAML
# error naming the TEMPLATE while the operator's value appears nowhere in
# it, or - when the content is a legal plain scalar that simply is not a
# string - the render succeeds and the API server rejects the object at
# apply time with "cannot unmarshal bool into Go struct field
# EnvVar.value of type string".
#
# WHY THIS JOB EXISTS AT ALL, rather than the install-test covering it: the
# object that carried the defect is rendered by no fixture. paperless-ngx'
# consume CronJob sits behind smbConnection.enabled, false in the defaults
# and in every committed fixture, so nothing in this repository ever
# produced it - which is how an unquoted schedule shipped for months. The
# overlays under ci/yaml-scalar/ switch such objects on and feed them
# hostile values, and the script fails when an overlay stops reaching its
# object.
#
# No cluster and no Trivy; the whole repository in well under a minute,
# depends on no other job. The YAML is parsed by Helm through ci/yaml2json,
# because the runners have neither pyyaml nor yq and a hand-written parser
# could read nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered scalar that must be a string"
run: ci/run-yaml-scalar-render-test.sh
networkpolicy:
name: "NetworkPolicy"
runs-on: ubuntu-latest
# The sixth job that ignores the changed-chart matrix and the fifth that runs
# over *every* chart (Gitea #5), for the same reason as the render tests
# above: the mistake arrives by copy into a chart whose own diff nobody reads
# for it - and since #5 this convention is in all 29 installable charts, so
# there is a lot of copy.
#
# What it asserts, on every NetworkPolicy a chart renders, against the pod
# specs, probes and Services of the SAME render: the podSelector really
# matches a pod; every ingress port is a containerPort of those pods; no
# ingress rule allows every source by accident; and every port a NETWORK
# probe addresses is either left unrestricted or comes with
# networkPolicy.probeSources and the consequence written into NOTES.txt.
# Plus two guards of its own: a chart that renders NO policy fails, and a
# rendered "#" comment fails.
#
# And one assertion that spans TWO charts (Gitea #136), the only one here
# that does: where a chart dials the Service of another chart of this
# repository, its EGRESS rule must contain that chart's POD ports. kube-proxy
# does the ClusterIP's destination NAT before the policy is evaluated, so a
# rule naming the SERVICE port matches nothing wherever port and targetPort
# differ - paperless-ngx shipped exactly that and lost Tika and Gotenberg in
# production on 2026-10-15. The pairs are an explicit table in the script,
# because no render can discover which Service another chart dials, and the
# row is checked with the feature gate on AND off.
#
# Every one of those failures is SILENT. The API server accepts a policy
# whose selector matches nothing - it then sits in the namespace, is listed
# by kubectl, reads as a boundary and filters exactly nothing - just as
# readily as it accepts one naming a NodePort number, which matches no
# traffic at all because the policy is evaluated at the pod's interface
# after kube-proxy has already translated the port. Neither is a render
# error and neither denies anything, so neither the build nor a rollout can
# see it.
#
# It does NOT replace the install-test, which is the real net under this
# convention: that one proves ENFORCEMENT on a live k3s with its policy
# controller running, and it is what makes "all charts at once, default on"
# defensible. This job is the cheap half, and it covers the two charts the
# install-test cannot reach at all - satisfactory and template-chart, both
# in ci/excluded-charts.txt.
#
# No cluster and no Trivy; a few seconds for the whole repository, depends
# on no other job. The YAML is parsed by Helm through ci/yaml2json, because
# the runners have neither pyyaml nor yq and a hand-written parser could read
# nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered NetworkPolicy against the render it belongs to"
run: ci/run-networkpolicy-test.sh
trivy-config:
name: "Trivy config ${{ matrix.chart }}"
needs: detect-changed-charts
+6 -9
View File
@@ -48,7 +48,7 @@ Flat layout, one Markdown file per page, page name = file name, links are relati
| a Renovate rule added or changed | `Renovate` |
| something broke in a way a rule now prevents | `Lessons-Learned` |
**What does not oblige an edit:** an app-version bump, at any level. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. Since Gitea #128 **"plain" is mechanically defined instead of being a judgement**: the chart version now states the size of the jump, because `ci/renovate-bump-chart.sh` takes that size from Renovate's update type (app minor → chart minor). An app update that *does* change values or behaviour is not a plain bump and the table applies — but **nothing obliges anyone to go and find out**. That is the deliberate limit of #128, not an oversight: the reading duty was rejected, so the `sonarr` incident in `Lessons-Learned` stays repeatable and a bigger number is the only warning that is given.
**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. An app update that *does* change values or behaviour is not a plain bump, and the table applies.
Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer.
@@ -94,13 +94,11 @@ Copy `template-chart/` and replace every `xxx` placeholder — in file names (`t
`Chart.yaml` versions follow `version: <chart-semver>+up<appVersion>` (e.g. `1.4.0+up1.9.2`), with `appVersion` matching the suffix (dashes in the app version are normalized to dots in the suffix). Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change.
Semver conventions for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required stays a major even under an app patch:
Semver conventions for the chart part:
- **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update.
- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required); an app **major** update.
The app half of that is mechanical: `ci/renovate-bump-chart.sh` takes the level from **Renovate's update type**, passed in as `{{{updateType}}}`, and not from comparing `appVersion` — `freesailarr` pins every tag in `values.yaml` and its `appVersion` never moves, so a comparison is blind to exactly the case the rule exists for. Non-semver app versions (CalVer, date tags, four-part and suffixed tags — seven charts) stay at **patch** for a minor, because their second position is a month or a flavour and says nothing about the jump; an app **major** still produces a chart major. **It obliges nobody to read the release notes** — that variant was rejected in #128 — so an app minor that changes a default can still reach `main` unremarked; the number stops understating, that is all it does. See the wiki, `Lessons-Learned`.
- **Patch**: bug fixes without behavior or values-schema change; plain app-version updates.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults).
- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required).
App-version updates go through PRs (so the validate and install-test pipelines run), not through the `change-app-version.yml` dispatch workflow (which commits directly to main and bypasses both). For app **major** jumps, update stepwise: first to the last release of the old major line (own PR, merge, publish), then to the newest major.
@@ -116,8 +114,7 @@ The binding style guide for all current and future charts lives in **README.md (
- Every container offers an additive `env` block rendered through `tpl` with `value`, `secretKeyRef`, and `configMapKeyRef` forms.
- Hardening: `automountServiceAccountToken: false`, non-root, `readOnlyRootFilesystem: true`, caps dropped, `allowPrivilegeEscalation: false`; contexts values-configurable (`securityContext.pod` / `securityContext.container`); writable paths as emptyDirs; image-forced deviations MUST be documented with a comment in the chart's `values.yaml`.
- Probes: liveness/readiness/startup on every workload (exception: paperless consume CronJob), values-configurable with calibrated defaults.
- **Every chart renders a `NetworkPolicy`, `networkPolicy.enabled` defaults to `true`** (#5); adding one to an existing chart is a **major**. Values: `enabled`, `allowedNamespaces`, `probeSources`, `ingressController`, `egress.extraPorts` — and only the ones that actually reach a rule, because a value that does nothing reads as a control. Each port falls in one of four classes and the class decides the rule: a port a **network** probe addresses stays open (`no from`) until `probeSources` is set, because the kubelet probes from the worker node's IP and no chart can know it; a NodePort-published port stays open (the source is already masqueraded); anything else is restricted to the namespace, `allowedNamespaces` and — for HTTP — `ingressController`; a replica-to-replica bus gets a bare `podSelector`. **`exec` probes are not network probes** (no packet, so the port can be restricted — `valkey`, `minecraft-server`). **`allowedNamespaces` is never shipped pre-filled** — a chart must not bake in an assumption about this cluster's namespace layout, so restrictive is the default and filling it is the deployment adaptation (which is why a sweep produces release-side one-liners: that is the procedure, not a defect list). **A policy applies to traffic that came through a Service** — a Service is only destination NAT and the policy acts at the pod interface; what changes is the *source address*, preserved for pod→ClusterIP→pod (this is why the `ingressController` rule works) and masqueraded to a node for NodePort traffic. **And the whole convention depends on one setting it cannot check: Traefik must not run with `hostNetwork`** — measured, its pods have real pod IPs in `10.42.0.0/16`; on host networking they stop being selectable as pods and every web chart goes dark at once while every pod stays `Ready`. **Pod ports, never NodePorts.** Never `from: []`. Reasoning in Go template comments, never `#` (those ship to the cluster), and such a comment must not spell out its own closing delimiter. Egress is by **port, never destination** — Canal has no FQDN rule and Calico-specific objects are out of scope — so most dependency ports are the *standard* ones and a dependency elsewhere is blocked; `policyTypes` names only what there is something to restrict (`valkey`, `homeassistant`, `satisfactory` are Ingress-only, each argued in its own file). `ci/run-networkpolicy-test.sh` is the gate. Full rules: README, "NetworkPolicies".
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion.
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and since #128 that republish carries the **subchart's own level** — an upstream minor is a chart minor, which is the only place that jump becomes visible at all; it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
+16 -159
View File
@@ -68,8 +68,8 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160. That is also what makes a manual `workflow_dispatch` of any `publish-<chart>.yml` a harmless way to prove the shared block works.
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **eight** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh`, `ci/run-selector-match-test.sh`, `ci/run-httproute-match-test.sh`, `ci/run-yaml-scalar-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test**, the **selector-match render test**, the **HTTPRoute render test** and the **YAML-scalar render test** are the four exceptions to that rule: all of them run over every chart on every PR — see below. A fourth job, the **bump-level test**, ignores the matrix for a different reason: it tests no chart at all, but the `postUpgradeTasks` hook that computes their versions.
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **seven** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh`, `ci/run-selector-match-test.sh`, `ci/run-httproute-match-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test**, the **selector-match render test** and the **HTTPRoute render test** are the three exceptions to that rule: all of them run over every chart on every PR — see below.
- `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser.
**It stays in this repository, and that is a decision, not an oversight** (Gitea #66, decision 4). It is reusable in form but not in substance: it is welded to the `ci/` fixtures, to the `ci/excluded-charts.txt` parser and to this repository's chart matrix. A building block that only one repository can use is not shared, it is merely moved somewhere else — and moving it would buy nothing while putting a tag between us and a file we change often. `publish-helm-chart.yml` was the opposite case: it takes a chart name and a registry owner and knows nothing about this repository, which is exactly why it could go. This note exists so the question is not asked again in half a year.
@@ -156,11 +156,10 @@ Neither is a render error, neither fails the install-test, and neither shows up
Three things to know before touching it:
- **Helm is the YAML parser, through the one-template chart `ci/yaml2json`.** The runners have neither `pyyaml` nor `yq` (measured; the same constraint `ci/run-trivy-config.sh` and `ci/run-quantity-render-test.sh` work under), and a YAML reader written in `awk` was the obvious alternative and the wrong one: its failure mode is a parser that quietly reads nothing, finds no selector, reports no mismatch and leaves the test green for a chart it never looked at. So the rendered manifest stream is handed to that chart as a **values file** and comes back as JSON, which `python3` reads from the standard library. The one textual step is the document split on a line that is exactly `---` in column one, and getting *that* wrong does not produce a false pass — the fragment stops being valid YAML and `helm` exits 1.
- **What is deliberately not checked, named so the gap is not mistaken for coverage.** *Preferred* `podAffinity` and `podAntiAffinity` of either strength are out of scope: their semantics are relative to pods that exist, so a term matching nothing is a legitimate configuration rather than a mistake — a preference that cannot be met is dropped, and an anti-affinity over zero pods is satisfied. And a selector expressed **only** through `matchExpressions` is **reported as "not checked", never counted as passing** — evaluating set-based requirements means reimplementing label-selector semantics, which is a second parser by another name.
- **`requiredDuringSchedulingIgnoredDuringExecution` `podAffinity` *is* checked** (Gitea #101), and it is the one affinity case that is not a preference: a required term whose selector matches no pod in the release can never be satisfied, so the pod stays `Pending` for good. It is held against **all** pod templates of the render rather than its own, because selecting a *different* workload's pod is the entire purpose of such a term. First and so far only user: `paperless-ngx`' consume CronJob, pinned onto the node of its StatefulSet pod so Longhorn will mount the shared RWX consume volume — where the silent failure would be a mover that stops moving, one `Pending` pod per schedule, with both templates still rendering perfectly.
- **Two anti-vacuum guards, because this test can be green over nothing.** Both objects are rendered only above a replica threshold, and most charts' CI fixtures sit below it. `ci/selector-match/<chart>.yaml` is a render-only overlay applied **last**, whose only job is to push a chart over that threshold — and a chart that *has* one and still renders no selector **fails**, since that overlay existing is the whole claim that something was checked. On top of that the run fails if **no selector at all** was checked anywhere. Today `stalwart` is exercised through its overlay (two replicas in both node groups plus an `externalServices.valkey.host`, without which the chart's own "cluster with no in-memory store" abort fires), `keycloak` through its ordinary install-test fixture, which already runs two replicas, and `paperless-ngx` through `ci/selector-match/paperless-ngx.yaml`, whose only job is to switch `smbConnection` on so the consume CronJob — and with it the required `podAffinity` — renders at all.
- **What is deliberately not checked, named so the gap is not mistaken for coverage.** `podAffinity`/`podAntiAffinity` are out of scope: their semantics are relative to pods that exist, so a term matching nothing is a legitimate configuration rather than a mistake. And a selector expressed **only** through `matchExpressions` is **reported as "not checked", never counted as passing** — evaluating set-based requirements means reimplementing label-selector semantics, which is a second parser by another name.
- **Two anti-vacuum guards, because this test can be green over nothing.** Both objects are rendered only above a replica threshold, and most charts' CI fixtures sit below it. `ci/selector-match/<chart>.yaml` is a render-only overlay applied **last**, whose only job is to push a chart over that threshold — and a chart that *has* one and still renders no selector **fails**, since that overlay existing is the whole claim that something was checked. On top of that the run fails if **no selector at all** was checked anywhere. Today `stalwart` is exercised through its overlay (two replicas in both node groups plus an `externalServices.valkey.host`, without which the chart's own "cluster with no in-memory store" abort fires) and `keycloak` through its ordinary install-test fixture, which already runs two replicas.
Measured: 30 charts, **6 selectors verified**, no cluster. The fail path was exercised rather than assumed — renaming `node-group` to `nodegroup` in a hand-written copy of the PDB selector produced two `FAIL` lines naming both the selector and the labels actually present, and a non-zero exit; the same was done for the spread constraint's selector. For the `podAffinity` check the same was done by renaming the StatefulSet's `app` label in a throwaway copy of `paperless-ngx` while leaving the CronJob's term alone: one `FAIL` naming the selector, the three pod templates it missed and the labels actually present, and a non-zero exit.
Measured: 30 charts, **5 selectors verified in ~14 s**, no cluster. The fail path was exercised rather than assumed — renaming `node-group` to `nodegroup` in a hand-written copy of the PDB selector produced two `FAIL` lines naming both the selector and the labels actually present, and a non-zero exit; the same was done for the spread constraint's selector.
### HTTPRoute render test (all charts, every PR)
@@ -188,67 +187,6 @@ Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job
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.
### Bump-level test (the version-bump hook, every PR)
`ci/run-bump-level-test.sh` (no arguments) holds every case of `ci/renovate-bump-chart.sh` against the version string it writes. Added with Gitea #128, which is what made it necessary: until then the hook was a calculator with one branch, and with the update type coming from Renovate it became the thing that *says how big a change a cluster is being offered* — while the chart version is the only signal anything downstream sees (see "Chart dependencies": Fleet reads our chart version and nothing inside the package).
Its failure mode is the silent kind this section's two rules are about: **a wrong level is a valid version string.** The PR goes green, the chart publishes, and the only symptom is a number that understates what it ships. Nothing in the pipeline reads that number, so nothing but a test can.
A case is not a function call, because the script reads the **base commit** (`git show HEAD:<chart>/Chart.yaml`) *and* the working tree: each of the 49 cases gets a throwaway `git init` under `mktemp -d`, one commit carrying the base `Chart.yaml`, and the new one written over it uncommitted — the state Renovate's `postUpgradeTasks` hook actually runs in. Signing is switched off per command, since this workspace signs through an SSH signer and a throwaway repo has no business invoking it. No Helm, no cluster, no `pyyaml`, no `yq`, no Docker: the fixtures are six-line `Chart.yaml` files written with `printf`, and the assertion is one `version:` line read back with `sed`. The job therefore carries no Helm step and takes about a second.
What the cases cover: each update type, the `freesailarr` shape (an app minor while `appVersion` stands still), all seven non-semver app versions plus the majors that must *not* fall back, the maximum rule in both directions including a stale branch version that must **not** be kept, a missing and an empty and an unknown second argument, and the inputs the script must leave alone — a chart new on the branch, `template-chart`, a `.github/` or `ci/` argument, no argument at all. The full `version:` line is asserted in every case, so the `+up` suffix and its dash-to-dot normalization are covered throughout.
**What it cannot assert, and does not pretend to:** that Renovate passes the update type at all. The hook runs only when the command is allow-listed on the shared instance, which is outside this repository — which is why a *missing* second argument is itself a case here, and why the change that introduced it carries a dry run instead of a claim (see "The version-bump hook").
### YAML-scalar render test (all charts, every PR)
`ci/run-yaml-scalar-render-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and checks that **every scalar Kubernetes types as a string really arrived as one**. Introduced with Gitea #11, where a value rendered without `| quote` had been shipping in a chart for months; #112 turned out to be the same defect in the same chart's `env` block. Three assertions, and all three are properties the API itself requires — there is no chart for which the opposite is a legitimate configuration, which is why this test needs no per-chart expectations:
| Assertion | What a bare render does | Who notices today |
|---|---|---|
| `CronJob.spec.schedule` is a non-empty string | a schedule beginning with `*` (the commonest cron notation there is) or with `@` **aborts the render** — YAML reads the leading character as an alias or a reserved indicator | the operator, eventually: the error names the **template** and their own value appears nowhere in it, and **quoting it in the values file does not help**, because Helm passes the string's content through |
| every `env[].value` of every container and initContainer is a string | `"true"` and `"0755"` are legal plain scalars that are **not strings**: the chart renders perfectly and the API server rejects the object with `cannot unmarshal bool into Go struct field EnvVar.value of type string` | nobody until apply time — which in this repo means the install-test or a cluster, never the render |
| every `metadata.labels` and `metadata.annotations` value is a string | both are typed `map[string]string`, so a unit-less `100` in an upload-size annotation is rejected whole | nobody in the render |
**Why this job exists next to the render tests that already run:** the object that carried the original defect is rendered by **no fixture at all**. `paperless-ngx`' consume CronJob sits behind `smbConnection.enabled`, which is `false` in the chart defaults and `false` in its install-test fixture — deliberately, since the CI cluster has no SMB CSI driver. So nothing in this repository ever produced that object: not lint, not the other render tests, not the install-test. A test that renders only what the fixtures already render would have been just as blind, which is the whole argument for the overlays below.
Three things to know before touching it:
- **Helm is the YAML parser**, through `ci/yaml2json`, for the same reason as the two tests above — the runners have neither `pyyaml` nor `yq`, and a parser written in `awk` fails by reading nothing and reporting nothing.
- **Two anti-vacuum guards.** `ci/yaml-scalar/<chart>.yaml` is a render-only overlay applied **last**, whose job is to switch such objects on *and to feed them the hostile values* — a schedule beginning with a star, an `env` value that is a bare `true`. A chart that *has* one and still renders none of the three kinds **fails**, and the run fails if **nothing at all** was asserted anywhere.
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. A value rendered into a scalar that is **hand-quoted in the template** (`"{{ .Values.x }}"`) is a string in the output by construction, so this test is blind to it — what it can still do is break the YAML when the value itself contains a quote or a backslash, and only `| quote` fixes that. Everything that is **not** a label, an annotation, an `env` value or a schedule: a bare `storage:` or `className:` is just as unquoted, but its value space is DNS labels and quantities, where no legal value is YAML-ambiguous. And **whether a cron schedule is a *valid* schedule** — that is the controller's judgement, and narrowing the schema to a regex was considered and rejected, because the defect was never a wrong schedule but a correct one rendered bare.
Measured locally: **30 charts, 408 scalars verified, 0 failed**, no cluster. The fail path was exercised rather than assumed, in all three of its shapes, against a throwaway copy of the chart as it stands on `main`: the unquoted schedule aborted the render with `yaml: line 6: did not find expected alphabetic or numeric character` and the test reported it; with only the schedule quoted, the star-valued `env` entry aborted the render on the StatefulSet instead; and with only the bool-looking value left — a render that **succeeds** — the type assertion produced a `FAIL` naming the variable, the container and the exact API-server message. **Seven charts still render their `env` block bare** (`matrix-synapse`, `minecraft-router`, `minecraft-server`, `rallly`, `satisfactory`, `teamspeak-server`, and `paperless-ngx` until #11); the test is green for them only because their fixtures set no hostile `env` value, and the overlay is what makes the difference between covered and merely green.
### NetworkPolicy render test (all charts, every PR)
`ci/run-networkpolicy-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and holds **every `NetworkPolicy` it renders against the pod specs, probes and Services of the same render**. Introduced with Gitea #5, which put a policy into all 29 installable charts at once with `networkPolicy.enabled: true` as the default — 29 objects whose every failure mode is silent, because the API server accepts a policy that filters **nothing** exactly as readily as one that filters **everything**.
| Assertion | What goes wrong without it | Who notices today |
|---|---|---|
| the `podSelector` matches a pod spec of the same render | the object sits in the namespace, is listed by `kubectl`, reads as a boundary and filters nothing at all — usually because a label was renamed in the workload template, a different file and a different diff | nobody, ever: nothing is denied, so there is no symptom to trace |
| 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 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 |
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 one exception is the fifth row, and it is 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 in the repository where that happens (checked for #136: the three other charts naming `port: 80` in a policy are aiming at the **internet**, where there is no DNAT and 80 is the real port). So the pairs live in an explicit table (`EGRESS_PEER_ROWS`) rather than being discovered — a chart cannot know which Service another chart dials, because the address is a value or a 1Password field — and a row naming a chart that no longer exists **fails** instead of being skipped. Each row is checked on **two** renders: with the feature gate **on** the containment must hold, and with it **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.
Two guards beyond the four, both earned rather than designed. **A chart that renders no policy at all fails** — that is how this issue decayed in the first place: `garage` (#81) and `keycloak` (#83) were both created *after* #5 was filed and both arrived without a policy, `keycloak` with its unauthenticated management surface on 9000. And **a `#` comment inside a rendered policy fails**, because a `#` comment is part of the *object*: it travels into the cluster, shows up in `helm get manifest` and `kubectl get -o yaml`, and lands in every release diff. Reasoning belongs in Go template comments, which render nothing. Both precedent charts failed that check when it was first run, which is what it is for.
Four things to know before touching it:
- **Assertion 3 is stricter than it first reads, deliberately.** A *missing* `from` is the documented open form and both precedent charts use it on purpose, so banning it outright was not an option. It is accepted only where the render itself justifies being open: every port of the rule is addressed by a network probe, or is published by a `NodePort` Service in the same render. Anything else is an allow-all nobody chose.
- **`exec` probes are not network probes**, and that distinction carries real weight rather than being a technicality. An `exec` probe runs inside the container through the kubelet's runtime and puts no packet on the network, so a chart whose probes are all `exec` can restrict its port by source with **no `probeSources` at all** — which is why `valkey` and `minecraft-server` have the tightest policies in the repository and every web app has a looser one.
- **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.
**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.
### Install-test (PR pipeline stage 2)
For every chart a PR changes, `ci/run-install-test.sh <chart>` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs.
@@ -329,9 +267,9 @@ Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — reposi
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one — a downgrade wearing the clothes of a routine date bump, which is exactly what a review waves through and automerge then carries to `main`. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no **app-version** block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. It does carry one `customManagers` block, for `mediathekarr`: that image is pinned by digest because its tags carry no version (`latest` is a 2024 build, `beta` moves), and the built-in `helm-values` manager reads only `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two cannot collide. Digest rules need the same guard as versioning rules and for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. Verified with two dry-run fixtures — the real digest yields no update, a deliberately stale one yields exactly one `digest` update back to the pinned digest.
A consequence of pinning every tag in `values.yaml`, and the measurement that forced the change in Gitea #128: `ci/renovate-bump-chart.sh` used to see an unchanged `appVersion` and bump the chart **patch** — for all ten images alike, the digest-pinned one included. `appVersion` here is the chart's own marker rather than a tracked upstream version, so a comparison had nothing to read: prowlarr's `2.5.2 → 2.6.5` was an upstream **minor** that arrived as a chart patch. Since #128 the level comes from Renovate's classification instead (`{{{updateType}}}`, the hook's second argument), so a minor among the ten makes a chart minor and a mixed group takes the highest level in it. The `+up` suffix still follows `appVersion` and therefore still stands still.
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
**That open point is now decided (Gitea #128), and the decision is deliberately smaller than the problem.** The gap was real: the automatic patch was also a *classification*, and the convention exempts a "plain app-version bump" from the wiki obligation — so an image update that quietly changed a **default** was the one case where nothing reviewed it and nothing documented it. It had already happened: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks and reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). What was decided is **the classification and not a reading duty**: the chart jump follows the app jump, so the number stops understating what it ships. The variant that would have caught that incident at its origin — *a chart minor or major obliges reading the upstream release notes, with the result stated in the PR even when it is "nothing found"* — was **rejected**, and its price is the part that stays: nobody is obliged to look behind the number, so an app minor that changes a default can still reach `main` unremarked. What the bigger number does buy is visibility where a patch has none — Fleet's `fleet.yaml` in `cluster-config` sees only our chart version (see "Chart dependencies"), so a minor is a number other gates can act on. Whether `cluster-config` actually distinguishes update types is **not** visible from this repository; that is the mechanism which makes the number effective, not a claim that it is in use.
**An open point this leaves, recorded rather than solved.** That automatic patch is also a classification, and the convention exempts a "plain app-version bump" from the wiki obligation — so an image update that quietly changes a *default* is the one case where nothing reviews it and nothing documents it. It has already happened once: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks, and it reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). Whether the convention should change is a separate decision and deliberately not proposed here.
Two dependencies are constrained beyond their versioning. **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning.
@@ -358,22 +296,13 @@ Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to majo
### The version-bump hook
Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: <chart-semver>+up<app-version>`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh <chart-dir> [update-type]` closes that gap and is wired up as a `postUpgradeTasks` command. **Since Gitea #128 the chart jump follows the app jump**, which turned the script from a calculator into deciding logic:
Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: <chart-semver>+up<app-version>`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh <chart-dir>` closes that gap and is wired up as a `postUpgradeTasks` command:
- **update type `major`** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions
- **update type `minor`** → chart **minor** (`6.0.0` → `6.1.0`) — the part that was missing, and where the incident sat
- **`patch`, `digest`, `pin`, `pinDigest`** → chart patch
- **no or unknown update type** → the pre-#128 behaviour: app major changed → chart major, anything else → chart patch
- **never below what the branch already carries** — the maximum rule below
- **app major changed** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions
- **anything else** (plain app update, sidecar image) → chart patch
- the `+up` suffix is recomputed from the new `appVersion`, dashes normalized to dots
**The level comes from Renovate, not from `appVersion`,** and that is the whole point: `freesailarr` keeps a hand-maintained `appVersion` for ten upstreams, so the update that prompted the rule moved an image tag in `values.yaml` while `appVersion` stood still (see "The one grouped chart" above). Verified rather than assumed — Renovate 44.155.7, keycloak pinned one minor back: `--platform=local --dry-run=full` classifies the update (`"updateType": "minor"`) but **never reaches the branch worker**, so it cannot show the hook at all; driving Renovate's own post-upgrade executor against a throwaway checkout does, and logs `cmd: "ci/renovate-bump-chart.sh keycloak minor"` with the script answering `1.3.0+up26.7.0 -> 1.4.0+up26.8.1 (update type minor)`.
**Non-semver app versions stay at patch for a minor**, with the reason in the convention rather than in a footnote: in CalVer and date tags the second position is a *month*, so following it would mint a chart minor every month that says nothing about the risk, and in a four-part or suffixed version the positions are not semver positions at all. Seven charts on `main` are in that class — `rss-bridge` (`2026-09-30`), `homeassistant` (`2026.8.3`), `searxng` (`2026.9.13-3b8009065`), `minecraft-server` (`2026.8.2-java25`), `apache-tika` (`3.3.1.0-full`), `teamspeak-server` (`6.0.0-beta13.1`), `valkey` (`9.1.2-alpine`) — and an app **major** is not subject to it, so a year rolling over in a date tag is a chart major exactly as it was before. The honest edge: for `6.0.0-beta13.1` and `9.1.2-alpine` the numeric part *is* a semver line, so the fallback understates a real minor there. That is the accepted trade — conservative by default; a chart that wants the minor earns a documented exception rather than a widened pattern.
**Maximum, not replacement.** The chart jump is the *greater* of the chart's own classification and the app jump, so a value that becomes required stays a chart major under an app patch. Mechanically it is also what makes a grouped branch deterministic: the hook runs once per update and every call recomputes from the same base commit, so without the maximum the last call would win and `freesailarr`'s minor-among-patches group would take whatever level it happened to end on. The script only ever keeps a *higher* version than it computes, which is why repeated runs stay idempotent, and everything else is derived from the base commit for the same reason.
`ci/run-bump-level-test.sh` holds every one of those cases (49 of them) against the version string the script writes — a wrong level is a valid version string, so the PR would go green and the only symptom would be a number that understates what it publishes.
Everything is derived from the base commit, so the script is idempotent across the multiple updates a single Renovate branch can carry.
> **One-time setup outside this repo:** `postUpgradeTasks` only runs on self-hosted Renovate **and** only when the command is allow-listed in the *instance* config:
>
@@ -382,8 +311,6 @@ Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `ve
> ```
>
> Without that entry Renovate skips the task without an error and every chart PR stays red on the version check.
>
> **The second argument depends on that pattern's shape, and the shape is not visible from here.** Renovate matches the allow-list against the **compiled** command (`allowedCommands.some((p) => regEx(p).test(compiledCmd))`), so the front-anchored form above still matches with an argument appended — measured, see the dry run noted in `renovate.json5`. A pattern anchored at the end on the instance (`…\.sh$`) would stop matching and produce the same silent skip a missing entry does. Only the maintainer can check that at the instance. It is why the script treats a **missing** update type as the pre-#128 behaviour instead of an error: the worst case of a narrower allow-list, an older Renovate or an unpopulated template field is the old classification, not red chart PRs.
If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's github.com credentials, not this config. The instance needs a read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups; its own platform token is a Gitea token and is worth nothing there.
@@ -444,11 +371,10 @@ These guidelines are **binding for every current and future chart** in this repo
### 2. Versioning
- `Chart.yaml` uses `version: <chart-semver>+up<appVersion>`; the `+up` suffix matches `appVersion`, with dashes normalized to dots (e.g. `0.0.36-beta` → `+up0.0.36.beta`). `appVersion` is the **exact image tag** of the main app image.
- Semver rules for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required is a chart major even under an app patch:
- **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update.
- **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required); an app **major** update.
- The app half of that is mechanical and belongs to the hook rather than to a reviewer's judgement: `ci/renovate-bump-chart.sh` takes the level from Renovate's update type, with non-semver app versions staying at patch for a minor and a missing update type falling back to the old behaviour. The full rule, the seven affected charts and what it deliberately does **not** oblige anybody to read are in "The version-bump hook" above.
- Semver rules for the chart part:
- **Patch**: bug fixes without behavior or values-schema change; plain app-version updates.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults).
- **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required).
- Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change.
### 3. Images
@@ -568,7 +494,6 @@ These guidelines are **binding for every current and future chart** in this repo
- Every workload container offers an additive `env` value block, appended to the env entries the chart always sets.
- Entries are rendered through `tpl` (so values may contain Helm templating) and support the `value`, `secretKeyRef` and `configMapKeyRef` forms (pattern: `template-chart/templates/xxx.deployment.yaml`).
- **`| quote` on the rendered `value`, and on the `name`/`key` of the ref forms, is part of the pattern and not cosmetic.** An env value is typed `string` by Kubernetes, so a value of `"true"` or `"0755"` rendered bare becomes a YAML bool or integer and the API server rejects the whole manifest with `cannot unmarshal bool into Go struct field EnvVar.value of type string` — after a perfectly green render. A value beginning with `*` is worse in the other direction: it aborts the render with a YAML error naming the template, while the operator's own value appears nowhere in the message, and quoting it in the values file does not help. **The rule is held by `ci/run-yaml-scalar-render-test.sh` on every PR** (Gitea #11/#112), which is also how the count below is known rather than assumed: `template-chart` carries the rule in a comment claiming "every chart in this repo quotes here", and **six still do not** — `matrix-synapse`, `minecraft-router`, `minecraft-server`, `rallly`, `satisfactory`, `teamspeak-server`. They are green only because no fixture of theirs sets a hostile value; bringing a chart to the quoted form is a **major** bump for it, since it changes rendered output for values that currently work.
- **Additive means additive: an entry repeating a name the chart already sets does not replace it, and what the pair does depends on when it arrives.** At install both entries reach the pod and the later one — the user's — wins. On an *existing* release the same entry is lost: Kubernetes merges `env` by name, the pair collapses to the chart's own value, and `helm upgrade` still reports success. Measured on `urlaubsverwaltung` (helm 3.19.0, Kubernetes 1.35): `helm get manifest` shows the variable twice while the live Deployment shows it once, carrying the chart's value. The failure mode is silence, so a chart whose `NOTES.txt` tells operators to override one of its own variables has to say that the override only takes at install time — and that a green upgrade proves nothing, the value belongs read back off the Deployment.
### 11. Scaling
@@ -659,7 +584,7 @@ One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubc
**What it costs, all of it visible before the next one is added:**
- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart version that ships it — since Gitea #128 at the **subchart's own level**, so an upstream minor is a chart minor even though our `appVersion` did not move. That chain is the entire safety net, and the number it writes is the only place the subchart's jump is visible at all. State this in a comment at the pinned version, not only in a review.
- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart patch that ships it; that chain is the entire safety net. State this in a comment at the pinned version, not only in a review.
- **Every pipeline that touches the chart needs a network fetch first.** `helm lint`, `helm package`, `helm template` and `helm install` all exit 1 while a declared dependency is missing from `charts/`, so `ci/helm-dependencies.sh` runs before each of them (see CI/CD above). One of those call sites is the resource-quantity render test, which runs over **every** chart on **every** PR — a dependency-carrying chart can therefore turn a third-party Helm repository's bad day into a red pipeline on a PR that touched nothing related.
- **`charts/*.tgz` and `Chart.lock` are not committed** (`.gitignore` says why): the archive is a third-party binary nobody reviews in a diff, and a committed lock would arrive stale because Renovate rewrites `Chart.yaml` and cannot regenerate it — which is also why the pipelines run `helm dependency update` and not `helm dependency build`.
- **The repo's own mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and the `scaleDown` flag reach our templates only. A chart that renders a `OnePasswordItem` for its subchart documents the manual `kubectl rollout restart` that replaces the missing restart-on-change.
@@ -667,71 +592,3 @@ One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubc
Two implementation notes that cost time otherwise: a values key containing a dash is unreachable as `.Values.oauth2-proxy` (that parses as a subtraction) and needs `index .Values "oauth2-proxy"`, while `condition:` in `Chart.yaml` splits on dots only and needs no escaping. And inside a subchart `.Chart.Name` is the **subchart's** name — a value templated into it (`config.configFile` in this case) must spell the parent chart out literally; only `.Release.Name` is shared.
Charts that need no dependency still have none, and `charts/` stays empty for all of them. `template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete.
### 15. NetworkPolicies (#5)
**Every chart renders a `NetworkPolicy`, and `networkPolicy.enabled` defaults to `true`.** Reference implementation: `template-chart/templates/xxx.networkpolicy.yaml`. The default is the decision the whole convention rests on — a policy that ships switched off is reached only when somebody remembers to switch it on, and then the issue is closed while nothing is filtered. Adding one to an existing chart is therefore a **major** bump.
**What the object is: a ceiling on reachability, not an authentication layer.** Three limits are real and worth having, and for most charts here the first two are where all of the value sits:
1. **Only the declared ports are reachable at all.** Whatever else the process binds — a debug listener, a management interface the image opens by default, a port that appears in the next app version — is blocked without anyone having to notice it exists.
2. **Egress is capped by port**, so a compromised pod cannot open arbitrary outbound connections.
3. **Ingress is capped by source for every port no network probe touches.**
#### The four port classes, and why the class decides the rule
Every port of a workload falls into one of four, and this is the part to get right before writing anything:
| Class | Rule | Why |
|---|---|---|
| **probe path** — a port a *network* probe addresses | **no `from`** by default; restricted once `networkPolicy.probeSources` is set | the kubelet probes from the IP of the **worker node running the pod**, an address no chart can know. A guessed CIDR that misses it drops every probe: the pod never becomes `Ready`, the rollout times out, and the symptom reads as a broken application rather than as a policy |
| **public** — published by a `NodePort` Service | **no `from`** | the senders are on the internet and, after NodePort masquerading, the source address is a cluster node anyway. A source selector could only ever be a list of node IPs — a restriction in appearance and a maintenance trap in fact |
| **closed** — no probe, not published outside | own namespace + `allowedNamespaces` (+ the ingress controller where the chart routes HTTP there) | this is the only class that yields a *real* inbound restriction, and the charts that have one are the ones whose probes use a *different* port: `keycloak`'s 8080, `garage`'s s3 and web, `minecraft-server`'s RCON, `teamspeak-server`'s query interface |
| **self** — a cluster bus between a release's own replicas | `podSelector` only, no `namespaceSelector` | the narrowest rule the object can express: "these labels, in this namespace". `keycloak`'s jgroups, `garage`'s rpc. Note that `policyTypes: Egress` means the **outbound** side needs its own rule, or the bus is open one way and the cluster never forms while every node looks healthy |
**`exec` probes are not in the probe class**, and the distinction is load-bearing rather than pedantic: an `exec` probe runs inside the container through the kubelet's runtime and puts no packet on the network, so there is no kubelet traffic to accommodate. That is the entire reason `valkey` and `minecraft-server` have the tightest policies in the repository — every port restricted by source, no `probeSources` value at all — and every HTTP-probed web app has a looser one.
#### 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`.
**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.
`ingressController` is the one exception to that, and only because it names a **cluster component** rather than a tenant: its default is measured below, and it is still overridable.
`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.
#### 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.
- **Most dependency ports are the standard ones, not the configured ones.** Databases, caches and OIDC issuers arrive as connection URLs or as fields inside 1Password items the charts never parse, so a chart cannot know where its dependency listens and cannot validate a guess. A dependency somewhere else is **blocked**, and `networkPolicy.egress.extraPorts` is the escape hatch. The exceptions are the two charts that hold their ports as values and therefore derive them: `keycloak` (`keycloak.db.port`) and `stalwart` (`dataStore.postgres.port`, `externalServices.*.port`); `minecraft-router` derives its backend ports from its own routing table the same way.
- **The probed port stays open inside the cluster by default.** For a single-port web app that means the policy is a port and egress ceiling, not pod-level isolation — say so in the chart rather than letting the object imply otherwise.
- **A subchart's pods are not covered.** The policy selects this chart's workload by label; a subchart renders its own pods with its own labels. `ytdlp-web-player`'s oauth2-proxy is the sharpest case — with the proxy on, the pod that actually faces the Ingress is unrestricted in both directions, and an upstream chart is consumed as it comes (section 14). `paperless-ngx`' `valkey` is the opposite: it brings its own, tighter policy. Name which case a chart is in.
- **And a policy is only a boundary where the CNI enforces one.** Unenforced, the API server still accepts it and nothing is filtered, so everything reads as configured. Verify by connecting from an unrelated namespace, not by reading the value.
#### Where the convention says "Ingress only"
`policyTypes` names only what there is something to restrict. `valkey` established it for a workload that opens **no** outbound connection: an egress rule would constrain nothing while making the object look stricter than it is. Three charts now invoke the same rule in the other direction, for outbound traffic nobody can enumerate — and each argues it in its own file rather than inheriting a blanket excuse:
- **`homeassistant`**: outbound traffic is the union of every integration an operator enables (mDNS and SSDP discovery, MQTT, a Zigbee or Z-Wave bridge on whatever port its firmware chose, ESPHome, printers, speakers, inverters, cloud APIs, NTP). A cap would protect little — the devices sit on the node's own LAN — and would break integrations one at a time, months apart, each looking like a flaky device.
- **`satisfactory`**: Steam's content delivery is not a pinnable port set, *and* this is the one chart with no install-test at all (`ci/excluded-charts.txt`), so an egress cap here would be the only one in the sweep with no empirical check behind it, on the chart whose correct port set is least knowable.
- **`gotenberg`** goes the other way and is worth contrasting: it *keeps* a DNS-only cap deliberately, because it drives a headless Chromium and 3000 is its probe port — so an egress rule for 80/443 would turn a service the whole cluster can POST to into an SSRF pivot. The price is that HTML conversion silently loses remote assets, which is written into the chart because nothing will alert on it.
#### Mechanics
- **Pod ports, never NodePorts.** A `NetworkPolicy` is evaluated at the pod's network interface, *after* kube-proxy has translated a NodePort to the `targetPort`. `30025` where the pod listens on `25` matches nothing: accepted, strict-looking, and blocking every inbound connection instead. The render test asserts this mechanically against the selected pod spec's `containerPort`s.
- **And pod ports, never Service ports — in `egress` too (#136).** 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, and a Service port only ever matches by coincidence — wherever `port` and `targetPort` are the same number, which is the case for `5432`, `6379`, `993` and `443` and is why this mistake stayed invisible until a chart dialled a Service that forwards `80` to `9998`. It was argued the other way in `paperless-ngx` (*"an egress rule names the port on the destination as the client dials it, and the client dials the Service"*), shipped, and cost an outage. **The tell is the symptom:** a dropped packet runs into a **timeout**, a missing listener answers `connection refused` — so a timeout where the service demonstrably exists is a policy question, and no Kubernetes error will say so. 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.
- **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 actually arrives.
- **`kubernetes.io/metadata.name`** is set by Kubernetes itself on every namespace (≥ 1.21), so namespace selectors need no labelling work on namespaces the chart does not own — and get no typo protection either.
- **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 goes in Go template comments, not `#` comments.** A `#` comment is part of the rendered object: it travels into the cluster, appears in `helm get manifest` and `kubectl get -o yaml`, and lands in every release diff. The render test fails on a rendered `#`. One second-order trap, found while writing the reference implementation: prose inside a template comment must not spell out the comment's own closing delimiter, or the parser ends the comment early and the rest is emitted as manifest content — the render then fails with `did not find expected node content`, pointing at the template and not at the sentence.
- **A conditional `containerPort` needs a conditional rule.** `minecraft-server`'s RCON port is rendered only when `server.rcon.enabled` is true; a rule without the same gate names a port the pod does not declare, and the render test rejects it.
- **A policy applies to traffic that arrived through a Service, and that is why the `ingressController` rule works at all.** A `NetworkPolicy` is evaluated at the pod's network interface and a Service is only destination NAT, so going through a ClusterIP does not exempt anything. What the Service *does* change is the **source address**: pod → ClusterIP → pod **preserves** it, which is what lets a `podSelector` match the Traefik pod or a `namespaceSelector` match a consumer's namespace. **And what it also changes is the destination port, which is the half #136 was paid for:** "a Service is only destination NAT" is exactly why an *egress* rule has to name the pod port — the same sentence, finished in the other direction. It was written here before the wrong rule was written there; the information was present and the conclusion was missing, which is the sort of gap no check catches. Traffic from outside through a `NodePort` with the default `externalTrafficPolicy: Cluster` is **masqueraded to a node**, which is why those rules cannot be restricted by source at all and are left open instead (`stalwart`'s template carries that second half; the first half is the load-bearing one).
#### The one cluster setting this convention depends on and does not control
**Traefik must not run with `hostNetwork`.** Measured on 2026-10-14: the `rke2-traefik` DaemonSet has no `hostNetwork`, and its pods carry real pod IPs (`10.42.3.120`, `10.42.4.94`, `10.42.0.187` — the pod CIDR is `10.42.0.0/16`, nodes are on entirely different addresses). **Only because of that does a `podSelector` match it**, and the `ingressController` value is built on exactly that.
If Traefik were ever switched to host networking, its pods would share the node's network namespace, they would no longer be selectable as pods, the `ingressController` entry would match nothing — and **every web chart in this repository would go dark at once**, in the restricted configuration. The policies would still be accepted, still read as correct, and deny all web traffic.
This is a dependency on a cluster setting this repository does not own, in the same class as the `--enable-certificate-owner-ref` case from #126: nothing here can detect it, and nothing here can fail usefully on it. It is written down in both places someone would look — here, and in the header of `template-chart/templates/xxx.networkpolicy.yaml` — so that whoever changes the ingress controller's networking finds the consequence attached to the thing they are changing. The symptom to recognise: every chart's web traffic stops at once while every pod stays `Ready`, because the probe path does not go through the controller.
+1 -1
View File
@@ -5,5 +5,5 @@ icon: https://www.apache.org/logos/res/tika/default.png
type: application
version: 11.0.0+up3.3.1.0.full
version: 10.0.2+up3.3.1.0.full
appVersion: "3.3.1.0-full"
-54
View File
@@ -58,57 +58,3 @@ THE JVM ONLY GETS A QUARTER OF THE MEMORY LIMIT
cheap regardless; the heap is what matters for parsers that have to build a
whole document in memory, and for OCR.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 9998 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }} — Tika is handed documents and
fetches nothing.
THIS CHART HAS NO INGRESS AND ITS CONSUMERS ARE OTHER PODS. While
networkPolicy.probeSources is empty the 9998 rule has no source selector, so
any pod in the cluster — including a paperless-ngx release in another
namespace — reaches it. Narrowing probeSources restricts that rule, and the
consumers' namespaces must then be listed in networkPolicy.allowedNamespaces
in the same change, or document extraction stops with a connection error on
the paperless side and nothing at all on this one.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,149 +0,0 @@
{{- /*
NetworkPolicy for apache-tika (Gitea #5).
Apache Tika here is a cluster-internal service: the chart creates no Ingress at
all, and its only consumers are other pods - paperless-ngx points at
http://<service>.<namespace>.svc.cluster.local for text extraction.
THAT MAKES THE SOURCE QUESTION THE INTERESTING ONE, AND IT HAS AN AWKWARD
ANSWER. 9998 is the port all three probes address, so it cannot be restricted
by source without the worker node's IP - and this chart's consumers live in
OTHER namespaces (paperless-cmk, paperless-jk, paperless-tb in this cluster).
So an operator who narrows probeSources has to list those namespaces in
allowedNamespaces in the same breath, or every document extraction stops. That
is written into values.yaml and NOTES.txt rather than left to be discovered.
Egress is DNS only. Tika extracts text from documents it is handed; it fetches
nothing, and a text extractor that can reach the internet is a worse thing to
hand an untrusted document to.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because there is something real to restrict: Tika extracts
text from documents it is handed and fetches nothing of its own, so DNS is the
only rule. A text extractor that can reach the internet is a worse thing to
hand an untrusted document to.
*/}}
- Egress
ingress:
{{- /*
9998 - the only port this pod declares, the one the Service forwards 80 to, and
the one all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 9998
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
NO ingress controller is added here, because this chart renders no Ingress: its
consumers are other pods, and they have to be named in allowedNamespaces.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-37
View File
@@ -9,43 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-60
View File
@@ -70,63 +70,3 @@ tika:
cpu: 0.1
memory: 512Mi
ephemeralStorage: 10Mi
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 9998 only - the single port this pod
# declares - and outbound nothing but DNS. Tika is handed documents and
# fetches nothing, which is a property worth keeping: a text extractor that
# can reach the internet is a worse thing to hand an untrusted document to.
#
# WHAT IT DOES NOT DO, and read this before narrowing anything: 9998 is the
# probe port, so it is NOT restricted by source in the default. This chart
# has no Ingress and its consumers are other pods - paperless-ngx releases,
# which in this cluster live in their own namespaces. The moment probeSources
# is set, the 9998 rule is restricted and those namespaces must be in
# allowedNamespaces, or every document extraction stops.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 9998, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Nothing is needed here for Tika itself. Add a port only if a parser is
# configured that fetches remote resources - and consider whether that is
# wanted at all.
extraPorts: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa
type: application
version: 2.0.0+up2.8.8
version: 1.1.0+up2.8.8
appVersion: "2.8.8"
maintainers:
-46
View File
@@ -153,49 +153,3 @@ bentopdf.env HAS NO EFFECT ON THE APP — AND THREE VALUES BREAK THE POD
starts. Using them means also setting
securityContext.container.readOnlyRootFilesystem: false.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8080 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }} — the documents are
processed in the browser, so the pod needs nothing more.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,157 +0,0 @@
{{- /*
NetworkPolicy for bentopdf (Gitea #5).
BentoPDF is a browser-side PDF tool: nginx serves the bundle, the documents are
processed in the visitor's browser, and the chart has no secrets and no
volumes. So the pod holds nothing and calls nothing, and the policy is as tight
as this object gets - inbound 8080, outbound DNS.
The ingress direction is not restricted by source in the default, because 8080
is the port all three probes address. See the rule below.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because the restriction is close to total and therefore worth
having: a static file server needs name resolution and nothing else, so DNS is
the only rule.
*/}}
- Egress
ingress:
{{- /*
8080 - the image's EXPOSE and the port its nginx.conf listens on, the one the
Service targets, and the one all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 8080
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress and the
HTTPRoute both go dark the moment this list is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-76
View File
@@ -299,79 +299,3 @@ httpRoute:
# chart has no OnePasswordItem, and why the checksum limitation for
# operator-filled secrets does not apply here: the only thing mounted is the
# ConfigMap, and that one is covered by checksum/config.
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8080 only, outbound nothing but DNS.
# BentoPDF processes documents in the visitor's browser and the chart has no
# secrets and no volumes, so the pod has no reason to open an outbound
# connection at all.
#
# WHAT IT DOES NOT DO: the 8080 rule is not restricted by source in the
# default, because 8080 is the probe port - see probeSources. So this is a
# port and egress ceiling, not pod-level isolation. And it is only a boundary
# where the CNI enforces policies.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8080, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Nothing is needed here for BentoPDF itself.
extraPorts: []
+1 -1
View File
@@ -21,7 +21,7 @@ type: application
# duplicated here. The short version: eight tag shapes, six of them things
# nobody should pin, and plain `docker` versioning excludes all six - so that
# block deliberately carries NO versioningTemplate.
version: 2.0.0+up1.12.0
version: 1.1.0+up1.12.0
appVersion: "1.12.0"
maintainers:
-53
View File
@@ -327,56 +327,3 @@ WHAT THIS CHART DOES NOT RENDER
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 3000 only. Outbound: DNS and TCP 443 — the JMAP server and the
OIDC provider{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
CHECK THE JMAP PORT. 443 is where a published JMAP endpoint lives. A
bulwark.jmapServerUrl pointing at Stalwart's in-cluster HTTP port (8080), or
at anything else, is BLOCKED by this policy — and because Bulwark's probes
only test its own port, the pod becomes Ready and then serves an empty,
broken webmail with nothing in the events to explain it. Add the real port to
networkPolicy.egress.extraPorts.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,175 +0,0 @@
{{- /*
NetworkPolicy for bulwark (Gitea #5).
Bulwark is a JMAP webmail CLIENT: it holds no mail, has no database, no cache
and no broker. Everything it shows lives on a Stalwart server reached over
bulwark.jmapServerUrl, and that single outbound dependency is what shapes the
egress rules.
AND IT IS THE ONE CHART WHERE THE EGRESS CAP CAN FAIL QUIETLY. Bulwark's probes
address its own HTTP port and say nothing about whether the JMAP server is
reachable - so a release whose JMAP URL sits on a port this policy does not
allow comes up Ready and then shows an empty, broken webmail. 443 is allowed
because that is where a published JMAP endpoint lives; a URL pointing straight
at Stalwart's in-cluster HTTP port (8080) is blocked and needs
networkPolicy.egress.extraPorts. That asymmetry is in values.yaml and NOTES.txt
because nothing else will report it.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed because there IS something to restrict: this workload
needs DNS and the ports named below, and nothing else. A chart whose
workload opens no outbound connection of its own must NOT list Egress -
valkey is the example - because a rule that constrains nothing makes the
object look stricter than it is.
*/}}
- Egress
ingress:
{{- /*
3000 - the only port this pod declares, targeted by both the regular and the
headless Service, and the port all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 3000
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress goes dark the
moment this list is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
Outbound HTTPS. This covers both of Bulwark's outbound dependencies: the JMAP
server named by bulwark.jmapServerUrl, and the OAuth/OIDC provider. Ports only
- the URLs are hostnames, which this object cannot match. A JMAP server reached
on its in-cluster HTTP port instead (Stalwart's 8080) is NOT covered and needs
networkPolicy.egress.extraPorts.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"description": "true renders the StatefulSet with replicas: 0 and takes precedence over bulwark.replicas. The PVC, Secrets, Service and Ingress stay in place.",
"type": "boolean"
-82
View File
@@ -816,85 +816,3 @@ httpRoute:
# unregistered origin is rejected at the provider, which says nothing about
# this chart.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 3000 only, and outbound DNS plus TCP 443 -
# which covers Bulwark's two outbound dependencies, the JMAP server
# (bulwark.jmapServerUrl) and the OIDC provider.
#
# WHAT IT DOES NOT DO, and this is the one to check before upgrading: 443 is
# where a PUBLISHED JMAP endpoint lives. If jmapServerUrl points straight at
# Stalwart's in-cluster HTTP port (8080) or anywhere else, that traffic is
# BLOCKED - and Bulwark's probes only test its own port, so the pod comes up
# Ready and serves a broken, empty webmail with nothing in the events to say
# why. Put the real port in egress.extraPorts below.
#
# Inbound 3000 is not restricted by source in the default (probe port), and a
# policy is only a boundary where the CNI enforces one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 3000, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# 8080 is the entry this chart needs most often: a jmapServerUrl pointing at
# Stalwart's in-cluster HTTP Service rather than at its published HTTPS
# name.
extraPorts: []
+1 -1
View File
@@ -5,7 +5,7 @@ name: calibre-web-automated
description: A Helm chart to deploy calibere-web-automated
icon: https://raw.githubusercontent.com/crocodilestick/Calibre-Web-Automated/main/README_images/cwa-logo-round-dark.png
version: 8.0.0+upv4.0.6
version: 7.1.0+upv4.0.6
appVersion: v4.0.6
maintainers:
-48
View File
@@ -217,51 +217,3 @@ TIME, AND THE ENVIRONMENT THE CHART ALREADY OCCUPIES
variable to disable WAL: NETWORK_SHARE_MODE=true" — so a library claim backed
by NFS or SMB, which is what cwa.storageClass decides, runs into exactly the
locking problems that setting exists to avoid.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8083 only. Outbound: DNS and TCP 443 (metadata providers){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }},
plus TCP {{ join ", " . }}{{ end }}{{ end }}. A send-to-Kindle or e-mail setup needs its SMTP port
(587 or 465) in networkPolicy.egress.extraPorts — without it the send fails
silently from this side.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,168 +0,0 @@
{{- /*
NetworkPolicy for calibre-web-automated (Gitea #5).
Calibre-Web-Automated keeps its library on a PVC and has no database and no
cache of its own, so the only outbound traffic that matters is metadata
lookups: cover art and book details fetched from public providers over HTTPS.
That is why 443 is allowed and nothing else is. The failure mode if it were
not: metadata lookups return nothing, which looks like a provider being down
rather than like a policy - so the rule is here rather than left to
extraPorts.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed because there IS something to restrict: this workload
needs DNS and the ports named below, and nothing else. A chart whose
workload opens no outbound connection of its own must NOT list Egress -
valkey is the example - because a rule that constrains nothing makes the
object look stricter than it is.
*/}}
- Egress
ingress:
{{- /*
8083 - the only port this pod declares, the one the Service forwards 80 to, and
the port all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 8083
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress goes dark the
moment this list is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
Outbound HTTPS for metadata providers (covers, book details). To any
destination, because the providers are hostnames and this object matches CIDRs
and selectors only.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-75
View File
@@ -189,78 +189,3 @@ httpRoute:
# CWA is told its address by nothing, so a second name needs no second value
# changed with it.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8083 only, and outbound DNS plus TCP 443
# for metadata lookups (covers and book details from public providers).
#
# WHAT IT DOES NOT DO: 443 goes to any destination - the providers are
# hostnames, which this object cannot match. Inbound 8083 is not restricted
# by source in the default, because it is the probe port. And a policy is
# only a boundary where the CNI enforces one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8083, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Add a port only for a metadata provider or an e-mail/send-to-Kindle setup
# that does not use 443 - SMTP submission would be 587 or 465.
extraPorts: []
-12
View File
@@ -160,15 +160,3 @@ ingress:
# also the annotation this chart's NOTES.txt tells operators to add, so the
# fixture is the shape a real install has.
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD
# ports of its dependencies. The CI JMAP server does not: fixtures.yaml serves it over plain HTTP on 8080 rather than over HTTPS on 443 - which is exactly the asymmetry values.yaml warns about for an in-cluster jmapServerUrl.
#
# So this is not a workaround for the test: it is the chart's documented escape
# hatch, used for exactly the case values.yaml names. The fixtures are the one
# place in the repository where that case is exercised, and removing this entry
# shows what the cap costs a dependency on a non-standard port.
networkPolicy:
egress:
extraPorts:
- 8080
-32
View File
@@ -1,32 +0,0 @@
# Overlay for the NetworkPolicy render test (Gitea #5), applied last.
#
# Two code paths this chart's own defaults never reach, and both are held
# against the four assertions on every pull request rather than checked once by
# hand:
#
# 1. networkPolicy.egress.remoteAssets: true - the named switch that lets
# Chromium fetch over 80/443. Its default is OFF, so the rule it renders
# existed in no render at all. It is an EGRESS rule, which is exactly why it
# is worth asserting: assertions 2 and 3 look at INGRESS rules, so a mistake
# on the egress side has no mechanical check except that the render still
# parses and the policy still holds together.
#
# 2. The source-restricted probe rule, which only appears once probeSources is
# non-empty - the same branch ci/networkpolicy/template-chart.yaml exists
# for, and the one the overlay guard demands of any chart that offers the
# value. It carries the expensive mistake: a namespaceSelector and a
# podSelector written as two list entries instead of one would be an OR.
#
# Note what this overlay does NOT do: it changes no release. gotenberg's
# install-test uses ci/gotenberg/test-values.yaml, which this file does not
# touch - the policy installed there keeps the restrictive default, because
# that is the default the convention ships.
networkPolicy:
egress:
remoteAssets: true
# Not the real node addresses of any cluster; documentation CIDRs
# (RFC 5737 TEST-NET-1) so nobody can mistake these for something to copy.
probeSources:
- 192.0.2.11/32
allowedNamespaces:
- paperless-jk
-31
View File
@@ -1,31 +0,0 @@
# Overlay for the NetworkPolicy render test (Gitea #5), applied last.
#
# This chart's policy has two branches that its own CI fixture never renders,
# and both carry a mistake worth catching mechanically.
#
# 1. THE NODEPORT BRANCH OF THE GAME-PORT RULE. The rule is OPEN when
# server.service.type is NodePort - players arrive from the internet,
# already masqueraded - and RESTRICTED to the namespace otherwise. The
# install-test fixture uses ClusterIP (as all four releases in this cluster
# do), so without this overlay the open branch was rendered NOWHERE: not by
# the defaults, not by the fixture, not by lint. It is also the branch where
# writing server.service.outwardPort instead of server.port would be the
# classic NodePort mistake, and assertion 2 only sees a rule it has been
# given.
#
# 2. THE RCON RULE, which is gated on server.rcon.enabled exactly as the
# containerPort is. That pairing was wrong once while this chart was being
# written - the rule had no gate, so it named a port the pod does not
# declare - and assertion 2 caught it. Switching RCON on here keeps the
# PAIR under test rather than just the gate.
#
# Note why this chart's overlay does NOT have to render a source-restricted
# probe rule, which is what the test demands of an overlay elsewhere: all three
# of this chart's probes are exec probes, so it has no probe rule at all and
# offers no networkPolicy.probeSources. The guard is conditional on that value
# for this reason.
server:
service:
type: NodePort
rcon:
enabled: true
-30
View File
@@ -1,30 +0,0 @@
# Overlay for the NetworkPolicy render test (Gitea #5), applied last.
#
# Its only job is to render a code path the chart's own defaults never reach:
# the SOURCE-RESTRICTED ingress rule. With probeSources empty - the default, and
# deliberately so - the probe port's rule carries no "from" at all, so the whole
# restricted branch of the template, the one containing the ingress-controller
# AND, the namespace list and the ipBlock peers, is never rendered and never
# checked. That branch is also the one with the expensive mistake in it: a
# namespaceSelector and a podSelector written as two list entries instead of one
# would be an OR, and would open the port to every pod in kube-system.
#
# ci/run-networkpolicy-test.sh FAILS a chart that has an overlay here and still
# renders no restricted rule. That guard is the point: without it the overlay
# could silently stop reaching the branch, and the test would stay green over a
# code path nobody entered.
#
# template-chart carries it rather than a real chart because it is installed
# nowhere (ci/excluded-charts.txt) - so the restricted form is exercised on
# every pull request with no release depending on these values.
networkPolicy:
# Not the real node addresses of any cluster; a documentation CIDR
# (RFC 5737 TEST-NET-1) so nobody can mistake these for something to copy.
probeSources:
- 192.0.2.11/32
- 192.0.2.12/32
allowedNamespaces:
- monitoring
egress:
extraPorts:
- 5432
-17
View File
@@ -8,20 +8,3 @@ ingress:
# clusterIssuer is strictly required (no default); the Ingress just has to
# apply - there is no cert-manager in the CI cluster.
clusterIssuer: ci
# The egress cap of the chart's NetworkPolicy (Gitea #5) allows 443 for the
# OIDC provider, because that is where an OIDC provider normally listens. The
# CI one does not: fixtures.yaml runs dex at
# http://dex.default.svc.cluster.local:5556/dex, and Outline fetches the
# issuer's discovery document at boot.
#
# So this entry is not a workaround for the test - it is the chart's documented
# escape hatch, used for exactly the case values.yaml names, and the fixture is
# the one place in the repository where that case is actually exercised. Remove
# it and the install-test shows what the cap does to a dependency on a
# non-standard port, which is the risk this convention carries into every
# release.
networkPolicy:
egress:
extraPorts:
- 5556
-12
View File
@@ -7,15 +7,3 @@ secrets:
ingress:
clusterIssuer: ci
domain: patchmon.ci.local
# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD
# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443.
#
# So this is not a workaround for the test: it is the chart's documented escape
# hatch, used for exactly the case values.yaml names. The fixtures are the one
# place in the repository where that case is exercised, and removing this entry
# shows what the cap costs a dependency on a non-standard port.
networkPolicy:
egress:
extraPorts:
- 5556
-12
View File
@@ -10,15 +10,3 @@ rallly:
ingress:
clusterIssuer: ci
domain: rallly.ci.local
# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD
# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443.
#
# So this is not a workaround for the test: it is the chart's documented escape
# hatch, used for exactly the case values.yaml names. The fixtures are the one
# place in the repository where that case is exercised, and removing this entry
# shows what the cap costs a dependency on a non-standard port.
networkPolicy:
egress:
extraPorts:
- 5556
+33 -189
View File
@@ -11,56 +11,23 @@
# failing the `build-chart` version-bump check. This script closes that gap and
# is wired up as a `postUpgradeTasks` command in renovate.json5.
#
# Usage: ci/renovate-bump-chart.sh <chart-directory> [update-type]
# Usage: ci/renovate-bump-chart.sh <chart-directory>
#
# Bump level (README "Versioning", decided in Gitea #128):
# - update type major -> chart major, minor/patch reset to 0
# - update type minor -> chart MINOR, patch reset to 0
# - patch / digest / pin -> chart patch
# - no or unknown update type -> the appVersion comparison below, i.e. the
# behaviour this script had before #128
# - and never BELOW what the branch already carries (see the maximum rule
# further down)
#
# THE UPDATE TYPE COMES FROM RENOVATE, AND THAT IS THE POINT (Gitea #128).
# Renovate has already classified the jump; passing `{{{updateType}}}` in is
# what makes a chart minor possible at all. Deriving it here from appVersion
# would be blind to exactly the case that prompted the rule: `freesailarr`
# carries a hand-maintained `appVersion: "1.0.0"` for ten upstreams and it never
# moves, so prowlarr's 2.5.2 -> 2.6.5 — an image tag in values.yaml — is a
# minor with an unchanged appVersion. README, "Dependency updates (Renovate)".
#
# WHAT A CHART MINOR BUYS, AND WHAT IT DOES NOT. It makes the number honest: an
# app minor stops arriving as a chart patch. It does NOT oblige anyone to read
# the release notes — that variant was decided against — so an app minor that
# changes a default can still reach `main` unremarked. What the number does is
# make the jump VISIBLE to everything downstream that sees only our chart
# version (Fleet's `fleet.yaml` in `cluster-config` among them), where a patch
# is invisible. See the wiki, Lessons-Learned.
#
# THE SECOND ARGUMENT IS OPTIONAL ON PURPOSE. `postUpgradeTasks` only runs when
# the command is allow-listed in the *instance* config, which is not visible
# from this repository, and a command that does not match the allow-list is
# skipped SILENTLY. So a missing update type — a narrower allow-list pattern, an
# older Renovate, a template field that is not populated — must not be a
# failure: without it the script falls back to what it did before #128 and every
# chart PR still gets its version bump.
# Bump level (README "Versioning"):
# - app major changed -> chart major, minor/patch reset to 0
# - anything else -> chart patch (plain app updates, sidecar images)
#
# Everything is derived from the base commit (HEAD, which during
# postUpgradeTasks still points at the base branch), never from the working
# tree's `version:`. That makes repeated runs on the same branch idempotent —
# Renovate calls the script once per dependency update, and a branch may carry
# more than one update for the same chart. The one place the working tree IS
# read is the maximum rule below, and only to keep a higher version.
# more than one update for the same chart.
#
# Runs inside the Renovate container, so: POSIX sh, no bashisms, no jq/yq.
#
# Tested by ci/run-bump-level-test.sh, which is where the cases live.
set -eu
dir=${1:-}
update_type=${2:-}
[ -n "$dir" ] || exit 0
# Strip a trailing slash so "outline/" and "outline" behave the same.
@@ -82,72 +49,6 @@ field() {
sed -n "s/^$1:[[:space:]]*//p" | head -n 1 | tr -d "\"' "
}
# Splits "<major>.<minor>.<patch>" into p_major/p_minor/p_patch. Returns 1 if
# the string is not exactly three numeric parts, which is also how a four-part
# version (3.3.1.0) and a suffixed one (9.1.2-alpine) are rejected: the last
# part then is not numeric.
parse_semver() {
_sv=$1
case "$_sv" in
*.*.*) ;;
*) return 1 ;;
esac
p_major=${_sv%%.*}
_rest=${_sv#*.}
p_minor=${_rest%%.*}
p_patch=${_rest#*.}
for _part in "$p_major" "$p_minor" "$p_patch"; do
case "$_part" in
'' | *[!0-9]*) return 1 ;;
esac
done
return 0
}
# Is <a.b.c> strictly greater than <d.e.f>? Six arguments, componentwise.
semver_gt() {
if [ "$1" -ne "$4" ]; then
if [ "$1" -gt "$4" ]; then return 0; else return 1; fi
fi
if [ "$2" -ne "$5" ]; then
if [ "$2" -gt "$5" ]; then return 0; else return 1; fi
fi
if [ "$3" -gt "$6" ]; then return 0; else return 1; fi
}
# Does this app version's MINOR POSITION carry a statement about jump width?
# Only a plain [v]X.Y.Z does. Everything else keeps the pre-#128 behaviour, i.e.
# a patch, and the reason is in the convention rather than in a footnote: in
# CalVer and in date tags the second position is a MONTH, so following it would
# produce a chart minor every month that says nothing about the risk, and in a
# four-part or suffixed version the positions are not semver positions at all.
#
# Measured on origin/main, this covers seven charts and only those seven:
# rss-bridge 2026-09-30 (no dots at all)
# homeassistant 2026.8.3 (CalVer — caught by the year guard)
# searxng 2026.9.13-3b8009065 (CalVer plus a commit hash)
# minecraft-server 2026.8.2-java25 (CalVer plus a JRE suffix)
# apache-tika 3.3.1.0-full (four parts plus a flavour)
# teamspeak-server 6.0.0-beta13.1 (prerelease line)
# valkey 9.1.2-alpine (flavour suffix)
#
# The last two are the honest edge of this rule: their numeric part IS a semver
# line, so the fallback understates a real minor there. That is the decided
# trade — conservative by default, and a chart that wants the minor gets a
# documented exception rather than a widened pattern.
#
# A `major` update type is NOT subject to this: a year rolling over in a date
# tag arrives as a major, and that was a chart major before #128 too.
app_minor_is_meaningful() {
_av=${1#v}
_av=${_av#V}
parse_semver "$_av" || return 1
# A four-digit leading component from 2000 on is a calendar year, not a
# semver major.
if [ "$p_major" -ge 2000 ]; then return 1; fi
return 0
}
base=$(git show "HEAD:$chart" 2>/dev/null) || {
echo "renovate-bump: $chart is new on this branch, leaving its version alone"
exit 0
@@ -156,7 +57,6 @@ base=$(git show "HEAD:$chart" 2>/dev/null) || {
base_version=$(printf '%s\n' "$base" | field version)
base_app=$(printf '%s\n' "$base" | field appVersion)
new_app=$(field appVersion <"$chart")
tree_version=$(field version <"$chart")
if [ -z "$base_version" ] || [ -z "$new_app" ]; then
echo "renovate-bump: $chart has no version/appVersion to work with" >&2
@@ -165,13 +65,27 @@ fi
# <chart-semver> is everything before the +up build suffix.
semver=${base_version%%+*}
parse_semver "$semver" || {
echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2
exit 1
}
major=$p_major
minor=$p_minor
patch=$p_patch
case "$semver" in
*.*.*) ;;
*)
echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2
exit 1
;;
esac
major=${semver%%.*}
rest=${semver#*.}
minor=${rest%%.*}
patch=${rest#*.}
for part in "$major" "$minor" "$patch"; do
case "$part" in
'' | *[!0-9]*)
echo "renovate-bump: non-numeric component in chart semver '$semver'" >&2
exit 1
;;
esac
done
# Leading major of an app version: v1.159.0 -> 1, 3.3.1.0-full -> 3,
# 2025-06-03 -> 2025.
@@ -179,87 +93,17 @@ app_major() {
printf '%s' "$1" | sed -e 's/^[vV]//' -e 's/[^0-9].*$//'
}
# The pre-#128 level: app major changed -> major, anything else -> patch. Also
# the fallback whenever no usable update type arrives.
app_version_level() {
if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then
echo major
else
echo patch
fi
}
case "$update_type" in
major)
level=major
reason="update type major"
;;
minor)
if app_minor_is_meaningful "$new_app"; then
level=minor
reason="update type minor"
else
level=$(app_version_level)
reason="update type minor, but appVersion '$new_app' is not a plain semver line -> $level"
fi
;;
patch | digest | pin | pinDigest)
level=patch
reason="update type $update_type"
;;
'')
level=$(app_version_level)
reason="no update type given, derived from appVersion -> $level"
;;
*)
level=$(app_version_level)
reason="unknown update type '$update_type', derived from appVersion -> $level"
;;
esac
case "$level" in
major)
new_major=$((major + 1))
new_minor=0
new_patch=0
;;
minor)
new_major=$major
new_minor=$((minor + 1))
new_patch=0
;;
*)
new_major=$major
new_minor=$minor
new_patch=$((patch + 1))
;;
esac
# THE MAXIMUM RULE (Gitea #128): the chart level is the GREATER of the chart's
# own classification and the app jump — never a replacement. Two cases, and the
# second is the mechanical one:
#
# - a chart-own bump already in the working tree stands. A value that becomes
# required is a chart major even when the app jump is a patch, and the chart
# version must not stop reporting the chart's own breaks.
# - a branch carrying SEVERAL updates. Renovate calls this script once per
# update and every call recomputes from the same base commit, so without
# this the last call would win: `freesailarr`'s grouped PR has arrived with
# three minors among five patches, and the chart level would then depend on
# the order the hook happened to run in.
#
# It only ever keeps a HIGHER version, so repeated runs stay idempotent.
if parse_semver "${tree_version%%+*}" &&
semver_gt "$p_major" "$p_minor" "$p_patch" "$new_major" "$new_minor" "$new_patch"; then
reason="$reason, kept the higher $p_major.$p_minor.$p_patch already on the branch"
new_major=$p_major
new_minor=$p_minor
new_patch=$p_patch
if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then
reason="app major $base_app -> $new_app"
new_semver="$((major + 1)).0.0"
else
reason="patch-level change"
new_semver="$major.$minor.$((patch + 1))"
fi
# Dashes in the app version become dots in the +up suffix.
suffix=$(printf '%s' "$new_app" | tr '-' '.')
new_version="$new_major.$new_minor.$new_patch+up$suffix"
new_version="$new_semver+up$suffix"
sed -i.bak "s|^version:.*|version: $new_version|" "$chart"
rm -f "$chart.bak"
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env bash
# Bump-level test: every case of ci/renovate-bump-chart.sh, held against the
# version string it writes into a throwaway chart.
#
# Usage: ci/run-bump-level-test.sh
#
# WHY THIS TEST EXISTS (Gitea #128)
#
# Until #128 the hook was a calculator with one branch: app major -> chart
# major, everything else -> chart patch. With the update type coming from
# Renovate it became DECIDING LOGIC — it is now the thing that says how big a
# change the cluster is being offered, and the chart version is the only signal
# anything downstream sees (README, "Chart dependencies": Fleet's `fleet.yaml`
# in `cluster-config` sees our chart version and nothing inside the package).
#
# And its failure mode is silence. A wrong level is a valid version string: the
# PR goes green, the chart publishes, and the only symptom is a number that
# understates what it ships. Nothing in the pipeline reads it, so nothing but a
# test can.
#
# HOW A CASE IS BUILT, AND WHY A REAL GIT REPO
#
# The script reads the BASE commit (`git show HEAD:<chart>/Chart.yaml`) and the
# working tree, so a case is not a function call — it is a repository with a
# commit and a dirty file. Each case therefore gets a throwaway `git init` in a
# unique temp directory, one commit carrying the base Chart.yaml, and the new
# Chart.yaml written over it uncommitted. That is exactly the state Renovate's
# `postUpgradeTasks` hook runs in.
#
# Signing is switched off per command (`-c commit.gpgsign=false`): this
# workspace signs every commit through an SSH signer, and a throwaway repo has
# no business invoking it. The temp root comes from `mktemp -d`, because /tmp is
# shared with whatever else runs on this machine.
#
# The runners have no pyyaml, no yq and no Docker — the same constraint
# ci/run-selector-match-test.sh and ci/run-quantity-render-test.sh work under.
# None is needed: the fixtures are six-line Chart.yaml files written by printf,
# and the assertion is one `version:` line read back with sed. Helm is not
# needed either, which is why this job carries no Helm step.
#
# WHAT IS ASSERTED, AND WHAT IS NOT
#
# Asserted: the full `version:` line (so the `+up` suffix and its dash-to-dot
# normalization are covered by every case), the exit code, and for the cases
# that must leave the chart alone, that the line is untouched.
#
# NOT asserted, and not assertable from here: that Renovate passes the update
# type at all. The hook only runs when the command is allow-listed in the
# *instance* config, which lives outside this repository — and a pattern that
# does not match is skipped silently. That is why a missing second argument is
# itself a case here (it must fall back, not fail), and why the PR that
# introduced this carries a dry-run instead of a claim.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/ci/renovate-bump-chart.sh"
[ -x "$SCRIPT" ] || {
echo "FAIL: $SCRIPT is not executable" >&2
exit 1
}
TMP="$(mktemp -d "${TMPDIR:-/tmp}/bump-level-test.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
GIT_OPTS=(-c user.name=ci -c user.email=ci@example.invalid -c commit.gpgsign=false -c tag.gpgsign=false)
passed=0
failed=0
chart_yaml() { # <name> <version> <appVersion>
printf 'apiVersion: v2\nname: %s\ndescription: test fixture\ntype: application\nversion: %s\nappVersion: "%s"\nicon: https://example.com/icon.png\n' \
"$1" "$2" "$3"
}
# Builds a repository whose HEAD carries <base-content> for <dir>/Chart.yaml
# (skipped when empty, which is the "new chart on this branch" case) and whose
# working tree carries <tree-content>. Prints the repository path.
scenario() { # <dir> <base-content> <tree-content>
local dir=$1 base=$2 tree=$3 repo
repo="$(mktemp -d "$TMP/case.XXXXXX")"
git -C "$repo" init -q
printf 'root commit so HEAD exists\n' >"$repo/README.md"
git -C "$repo" add README.md
git -C "$repo" "${GIT_OPTS[@]}" commit -qm "root"
mkdir -p "$repo/$dir"
if [ -n "$base" ]; then
printf '%s' "$base" >"$repo/$dir/Chart.yaml"
git -C "$repo" add "$dir/Chart.yaml"
git -C "$repo" "${GIT_OPTS[@]}" commit -qm "base chart"
fi
[ -z "$tree" ] || printf '%s' "$tree" >"$repo/$dir/Chart.yaml"
printf '%s' "$repo"
}
version_of() { sed -n 's/^version:[[:space:]]*//p' "$1" | head -n 1; }
report() { # <name> <expected> <got> <expected-rc> <got-rc> <output>
if [ "$2" = "$3" ] && [ "$4" = "$5" ]; then
passed=$((passed + 1))
printf ' ok %s\n' "$1"
else
failed=$((failed + 1))
printf ' FAIL %s\n expected version %-24s rc %s\n got version %-24s rc %s\n output: %s\n' \
"$1" "$2" "$4" "$3" "$5" "$6"
fi
}
# The ordinary case: one chart, a base version, an app version that may or may
# not have moved, and an update type. UPDATE_TYPE of "-" calls the script with
# ONE argument (the allow-list / older-Renovate case), "" passes an empty
# second argument (a template field that resolved to nothing).
bump() { # <name> <base-version> <base-app> <tree-version> <tree-app> <update-type> <expected-version> [expected-rc]
local name=$1 base_version=$2 base_app=$3 tree_version=$4 tree_app=$5 utype=$6 expected=$7 rc_expected=${8:-0}
local repo out rc got
repo="$(scenario demo "$(chart_yaml demo "$base_version" "$base_app")" "$(chart_yaml demo "$tree_version" "$tree_app")")"
rc=0
if [ "$utype" = "-" ]; then
out="$(cd "$repo" && "$SCRIPT" demo 2>&1)" || rc=$?
else
out="$(cd "$repo" && "$SCRIPT" demo "$utype" 2>&1)" || rc=$?
fi
got="$(version_of "$repo/demo/Chart.yaml")"
report "$name" "$expected" "$got" "$rc_expected" "$rc" "$out"
}
echo "== update type decides the level (plain semver appVersion) =="
bump "major -> chart major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 major 2.0.0+up2.0.0
bump "minor -> chart minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0
bump "patch -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 patch 1.2.4+up1.4.1
bump "digest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 digest 1.2.4+up1.4.0
bump "pin -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pin 1.2.4+up1.4.0
bump "pinDigest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pinDigest 1.2.4+up1.4.0
bump "minor resets the patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0
bump "major resets minor and patch" 4.5.6+up1.4.0 1.4.0 4.5.6+up1.4.0 2.0.0 major 5.0.0+up2.0.0
echo "== the case that prompted the rule: appVersion never moves (freesailarr) =="
# prowlarr 2.5.2 -> 2.6.5 is an image tag in values.yaml; freesailarr's
# appVersion is a hand-maintained marker and stays at 1.0.0. An appVersion
# comparison sees nothing and would bump the patch.
bump "sidecar/bundle minor, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "sidecar/bundle patch, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 patch 3.1.2+up1.0.0
echo "== non-semver app versions stay at patch (all seven on origin/main) =="
bump "rss-bridge date tag, month roll" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2026-10-05 minor 2.1.2+up2026.10.05
bump "homeassistant CalVer" 6.2.0+up2026.8.3 2026.8.3 6.2.0+up2026.8.3 2026.9.0 minor 6.2.1+up2026.9.0
bump "searxng CalVer plus hash" 1.1.0+up2026.9.13.3b8009065 2026.9.13-3b8009065 1.1.0+up2026.9.13.3b8009065 2026.10.1-aa12bb34c minor 1.1.1+up2026.10.1.aa12bb34c
bump "minecraft-server CalVer plus JRE" 6.0.1+up2026.8.2.java25 2026.8.2-java25 6.0.1+up2026.8.2.java25 2026.9.1-java25 minor 6.0.2+up2026.9.1.java25
bump "apache-tika four parts" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 3.4.0.0-full minor 10.0.3+up3.4.0.0.full
bump "teamspeak prerelease line" 5.1.3+up6.0.0.beta13.1 6.0.0-beta13.1 5.1.3+up6.0.0.beta13.1 6.0.0-beta14.1 minor 5.1.4+up6.0.0.beta14.1
bump "valkey flavour suffix" 1.0.0+up9.1.2.alpine 9.1.2-alpine 1.0.0+up9.1.2.alpine 9.2.0-alpine minor 1.0.1+up9.2.0.alpine
# A major is NOT subject to the fallback - a year rolling over was a chart major
# before #128 as well.
bump "date tag, year roll, major" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2027-01-05 major 3.0.0+up2027.01.05
bump "four parts, app major, major" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 4.0.0.0-full major 11.0.0+up4.0.0.0.full
# The year guard is a guard, not a blanket: a v-prefixed plain line still counts.
bump "v-prefixed plain semver, minor" 7.2.0+upv1.159.0 v1.159.0 7.2.0+upv1.159.0 v1.160.0 minor 7.3.0+upv1.160.0
echo "== maximum, not replacement =="
# A chart-own bump already in the working tree stands, whatever the app jump is.
bump "chart major already on branch, app patch" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 patch 2.0.0+up1.4.1
bump "chart minor already on branch, app patch" 1.2.3+up1.4.0 1.4.0 1.3.0+up1.4.0 1.4.1 patch 1.3.0+up1.4.1
# A grouped branch: the hook runs once per update from the same base, so the
# minor must survive a patch call that follows it.
bump "minor on branch, later patch call" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 patch 3.2.0+up1.0.0
bump "patch on branch, later minor call" 3.1.1+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "repeated minor call is idempotent" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "repeated major call is idempotent" 1.2.3+up1.4.0 1.4.0 2.0.0+up2.0.0 2.0.0 major 2.0.0+up2.0.0
bump "major on branch wins over a later minor" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.5.0 minor 2.0.0+up1.5.0
# Stale branch after a rebase: the base moved ahead of what the branch carries,
# so the recomputed version wins and nothing is kept.
bump "stale branch version is not kept" 3.3.0+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 patch 3.3.1+up1.0.0
echo "== missing and unknown update type fall back to the pre-#128 behaviour =="
bump "no second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 - 2.0.0+up2.0.0
bump "no second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 - 1.2.4+up1.5.0
bump "no second argument, app patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 - 1.2.4+up1.4.1
bump "no second argument, unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 - 3.1.2+up1.0.0
bump "empty second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 "" 2.0.0+up2.0.0
bump "empty second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 "" 1.2.4+up1.5.0
bump "unknown type, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 replacement 1.2.4+up1.5.0
bump "unknown type, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 lockFileMaintenance 2.0.0+up2.0.0
bump "unknown type, maximum still holds" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 nonsense 2.0.0+up1.4.1
echo "== charts the script must leave alone, and the hard failures =="
bump "unparsable chart semver fails loudly" 1.2+up1.4.0 1.4.0 1.2+up1.4.0 1.5.0 minor 1.2+up1.4.0 1
bump "missing appVersion fails loudly" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 "" minor 1.2.3+up1.4.0 1
new_chart_repo="$(scenario demo "" "$(chart_yaml demo 0.1.0+up1.0.0 1.0.0)")"
rc=0
out="$(cd "$new_chart_repo" && "$SCRIPT" demo minor 2>&1)" || rc=$?
report "a chart new on the branch keeps its version" \
"0.1.0+up1.0.0" "$(version_of "$new_chart_repo/demo/Chart.yaml")" 0 "$rc" "$out"
case "$out" in
*"new on this branch"*) passed=$((passed + 1)); printf ' ok and says so\n' ;;
*) failed=$((failed + 1)); printf ' FAIL new chart: no explanation in the output: %s\n' "$out" ;;
esac
tpl_repo="$(scenario template-chart "$(chart_yaml template-chart 4.2.0+upxxx xxx)" "$(chart_yaml template-chart 4.2.0+upxxx xxx)")"
rc=0
out="$(cd "$tpl_repo" && "$SCRIPT" template-chart minor 2>&1)" || rc=$?
report "template-chart is never touched" \
"4.2.0+upxxx" "$(version_of "$tpl_repo/template-chart/Chart.yaml")" 0 "$rc" "$out"
# Renovate calls the hook for every update it makes, chart or not.
other_repo="$(scenario demo "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)" "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)")"
for arg in ".github/workflows" "ci/gateway-api-crds.sh" "." "" "not-a-chart"; do
rc=0
out="$(cd "$other_repo" && "$SCRIPT" "$arg" major 2>&1)" || rc=$?
report "non-chart argument '$arg' is a no-op" \
"1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
done
rc=0
out="$(cd "$other_repo" && "$SCRIPT" 2>&1)" || rc=$?
report "no arguments at all is a no-op" \
"1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
rc=0
out="$(cd "$other_repo" && "$SCRIPT" demo/ patch 2>&1)" || rc=$?
report "a trailing slash is accepted" \
"1.2.4+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
echo
if [ "$failed" -gt 0 ]; then
printf 'bump-level test: %d passed, %d FAILED\n' "$passed" "$failed"
exit 1
fi
printf 'bump-level test: %d cases passed\n' "$passed"
File diff suppressed because it is too large. Load diff
+4 -65
View File
@@ -52,22 +52,10 @@
#
# WHAT IS DELIBERATELY NOT CHECKED
#
# - preferred podAffinity and podAntiAffinity of either strength. They carry
# labelSelectors too, but their semantics are "relative to pods that exist",
# and a term matching nothing is a legitimate configuration rather than a
# mistake: a preference that cannot be met is dropped and an anti-affinity
# over zero pods is satisfied. Out of scope, and named so that the gap is
# not mistaken for coverage.
#
# requiredDuringSchedulingIgnoredDuringExecution podAffinity is NOT in that
# gap and is checked (Gitea #101). Its failure is not a preference quietly
# dropped: a required term whose selector matches no pod in the release can
# never be satisfied, so the pod stays Pending forever. For the workload
# that introduced one here - paperless-ngx' consume CronJob, pinned onto the
# node of its StatefulSet pod so that Longhorn will mount the shared RWX
# volume - that would mean a mover that silently stops moving, with one
# Pending pod per schedule and a green render behind it. The term names a
# label written in ANOTHER template, which is the copy this test holds.
# - podAffinity / podAntiAffinity. They carry labelSelectors too, but their
# semantics are "relative to pods that exist", and an anti-affinity term
# matching nothing is a legitimate configuration rather than a mistake.
# Out of scope, and named so that the gap is not mistaken for coverage.
# - selectors expressed ONLY through matchExpressions. Evaluating a set-based
# requirement means reimplementing label-selector semantics, which is a
# second parser by another name. Those are REPORTED as "not checked" and
@@ -355,55 +343,6 @@ for doc in pdbs:
", ".join("%s=%s" % kv for kv in sorted(match_labels.items())),
len(all_templates), "; ".join(known) or "none"))
# 3. Every REQUIRED podAffinity term, against ALL pod templates of the render
# (Gitea #101). Not against its own template: the whole point of such a term
# is to select a DIFFERENT workload's pod, so the correct question is whether
# anything in this release carries the labels it asks for. Preferred terms and
# anti-affinity of either strength are left alone - see the header.
for where, template in all_templates:
affinity = template.get("spec", {}).get("affinity")
if not isinstance(affinity, dict):
continue
pod_affinity = affinity.get("podAffinity")
if not isinstance(pod_affinity, dict):
continue
terms = pod_affinity.get("requiredDuringSchedulingIgnoredDuringExecution")
if not isinstance(terms, list):
continue
for i, term in enumerate(terms):
if not isinstance(term, dict):
lines.append("FAIL|%s: required podAffinity[%d] at %s is not a mapping"
% (chart, i, where))
continue
tag = "required podAffinity[%d] on %s at %s" % (
i, term.get("topologyKey", "<no topologyKey>"), where)
match_labels, reason = classify(term.get("labelSelector"), tag)
if match_labels is None:
if term.get("labelSelector") is None:
lines.append("FAIL|%s: %s" % (chart, reason))
else:
lines.append("SKIP|%s: %s" % (chart, reason))
continue
hits = [w for w, t in all_templates if matches(match_labels, labels_of(t))]
if hits:
lines.append("OK|%s: %s matches %d pod template(s): %s"
% (chart, tag, len(hits), ", ".join(hits)))
else:
known = sorted({"%s=%s" % kv for _, t in all_templates for kv in labels_of(t).items()})
lines.append("FAIL|%s: %s selects %s, which matches NONE of the %d "
"pod template(s) in the same render. A REQUIRED "
"podAffinity term that matches no pod can never be "
"satisfied, so the pod it is attached to stays Pending "
"forever - for a CronJob that means one Pending pod per "
"schedule and a workload that silently stopped running, "
"with nothing in the render to show for it. The usual "
"cause is a label renamed in the template that carries "
"the selected pod. Labels actually present on the pod "
"templates: %s"
% (chart, tag,
", ".join("%s=%s" % kv for kv in sorted(match_labels.items())),
len(all_templates), "; ".join(known) or "none"))
for line in lines:
print(line)
PY
-412
View File
@@ -1,412 +0,0 @@
#!/usr/bin/env bash
# YAML-scalar render test: an operator value that must arrive in the manifest as
# a STRING is held against the manifest it actually produced.
#
# Usage: ci/run-yaml-scalar-render-test.sh [chart ...] (no argument = all charts)
#
# WHAT IT GUARDS AGAINST (Gitea #11, and #112 turned out to be the same genus)
#
# A value is rendered into a YAML scalar without `| quote`. The operator quotes
# it correctly in their values file, Helm passes the string's CONTENT through,
# and the quotes are gone again in the manifest. Two outcomes, and the quiet one
# is the worse one:
#
# 1. LOUD, but misdirected - the content is not a legal plain scalar and the
# render aborts. Measured on paperless-ngx' consume CronJob, whose schedule
# was unquoted: a schedule in the commonest cron notation of all (a star, a
# slash, an interval) made `helm template` exit 1 with
#
# error converting YAML to JSON: yaml: line 6: did not find expected
# alphabetic or numeric character
#
# YAML read the leading "*" as an alias indicator. The message names the
# TEMPLATE and the operator's own value is nowhere in it, so the search
# starts in the wrong place. "@daily" fails the same way.
# 2. SILENT until the API server - the content IS a legal plain scalar, just
# not a string. "true" becomes a bool, "0755" becomes an integer, and the
# chart renders perfectly. Kubernetes then rejects the object with
# "cannot unmarshal number into Go struct field EnvVar.value of type
# string" - at apply time, which for this repository means in the
# install-test or in a cluster, never in the render.
#
# Neither is caught by "does helm template exit 0" alone: the first one is, but
# only if something actually renders the object, and the second one is not
# caught at all.
#
# WHAT IS ASSERTED, and all three are properties Kubernetes itself requires -
# there is no chart for which the opposite is a legitimate configuration, which
# is why this needs no per-chart expectations:
#
# 1. CronJob .spec.schedule is a non-empty string.
# 2. Every env[].value of every container and initContainer is a string.
# 3. Every metadata.labels and metadata.annotations value is a string
# (the API types both as map[string]string).
#
# WHY OVER ALL CHARTS, not the changed ones: the same reason as
# ci/run-quantity-render-test.sh and ci/run-selector-match-test.sh. The env
# block is copied into every chart rather than shared, so the unquoted form
# travels by copy into whatever chart is started from an older one - and that
# chart's own pull request is not where anyone would look for it. Running over
# everything also means there is no list to maintain: adding a chart enrols it.
#
# THE OVERLAYS, AND WHY THIS TEST IS USELESS WITHOUT THEM
#
# The defect above survived in a shipped chart for months for one reason: the
# object that carried it is rendered by NO fixture. paperless-ngx' CronJob sits
# behind smbConnection.enabled, which is false in the chart defaults and in
# every committed fixture, so neither lint, nor the render tests, nor the
# install-test ever produced it. A test that renders only what the fixtures
# already render would have been just as blind.
#
# So ci/yaml-scalar/<chart>.yaml is an overlay applied LAST whose job is to
# switch such objects on AND to feed them the hostile values - a schedule
# beginning with a star, an env value that is a bare "true". Two rules follow,
# the same ones ci/run-selector-match-test.sh works under:
#
# 1. A chart that HAS an overlay and still renders none of the three things
# above FAILS. The overlay exists for exactly that, so an overlay that
# stopped reaching its object is a finding and not a shrug.
# 2. If nothing at all was asserted across the whole run, the run fails. A
# pass over zero assertions is not a pass.
#
# WHAT IS DELIBERATELY NOT CHECKED, so the gap is not mistaken for coverage:
#
# - Values rendered into a scalar that is hand-quoted in the template
# ("{{ .Values.x }}"). Those are strings in the output, so this test is
# blind to them by construction; what they can still do is break the YAML
# when the value itself contains a quote or a backslash, and only `| quote`
# fixes that. Reported here rather than silently implied.
# - Everything that is not a label, an annotation, an env value or a schedule.
# A bare `storage:` or `className:` is just as unquoted, but its value space
# is DNS labels and quantities, where no legal value is YAML-ambiguous. The
# three above are where operator free text actually lands.
# - Whether a cron schedule is a VALID schedule. That is the cron
# controller's judgement, and narrowing the schema to a regex was considered
# and rejected: the defect was never a wrong schedule, it was a correct one
# rendered bare.
#
# How the YAML is parsed: with helm, via ci/yaml2json, exactly as in
# ci/run-selector-match-test.sh - the runners have neither pyyaml nor yq, and a
# YAML reader written in awk here would fail by quietly reading nothing and
# staying green. That script's header carries the full argument.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="$ROOT/ci"
YAML2JSON="$CI_DIR/yaml2json"
OVERLAY_DIR="$CI_DIR/yaml-scalar"
RELEASE="scalar"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --- Which charts -----------------------------------------------------------
CHARTS=()
if [ "$#" -gt 0 ]; then
CHARTS=("$@")
else
for DIR in "$ROOT"/*/; do
[ -f "$DIR/Chart.yaml" ] || continue
CHARTS+=("$(basename "$DIR")")
done
fi
if [ "${#CHARTS[@]}" -eq 0 ]; then
echo "ERROR: no chart directories found under $ROOT" >&2
exit 1
fi
FAILED=""
CHECKED=""
NOT_EXERCISED=""
TOTAL_ASSERTIONS=0
SUMMARY_ROWS=""
for CHART in "${CHARTS[@]}"; do
if [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
echo "ERROR: '$CHART' is not a chart directory" >&2
exit 1
fi
# A chart with a declared dependency cannot be rendered at all while charts/
# is empty, and this job runs over EVERY chart on EVERY pull request.
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
VALUES_ARGS=()
VALUES_LABEL="chart defaults"
if [ -f "$CI_DIR/$CHART/test-values.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/$CHART/test-values.yaml")
VALUES_LABEL="ci/$CHART/test-values.yaml"
elif [ -f "$CI_DIR/trivy/values/$CHART.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/trivy/values/$CHART.yaml")
VALUES_LABEL="ci/trivy/values/$CHART.yaml"
fi
HAS_OVERLAY=""
if [ -f "$OVERLAY_DIR/$CHART.yaml" ]; then
# Last, so it wins over the install-test values.
VALUES_ARGS+=(-f "$OVERLAY_DIR/$CHART.yaml")
VALUES_LABEL="$VALUES_LABEL + ci/yaml-scalar/$CHART.yaml"
HAS_OVERLAY=1
fi
echo "::group::$CHART ($VALUES_LABEL)"
RENDER="$WORK/$CHART.manifest.yaml"
if ! helm template "$RELEASE" "$ROOT/$CHART" \
${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"} >"$RENDER" 2>"$WORK/err"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::$CHART does not render with these values. If the error is a YAML parse error naming a template line, that IS the finding this test exists for: a value reached a scalar unquoted. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")"
FAILED="$FAILED $CHART"
continue
fi
# The document split, the only textual step. A wrong split cannot produce a
# false pass: the result stops being parsable and helm exits 1.
DOCS="$WORK/$CHART.docs.yaml"
awk '
BEGIN { print "docs:"; started = 0 }
/^---[ \t]*$/ { print " -"; started = 1; next }
{
if (!started) { print " -"; started = 1 }
if (length($0) == 0) print ""; else print " " $0
}
' "$RENDER" >"$DOCS"
JSON="$WORK/$CHART.json"
if ! helm template y2j "$YAML2JSON" -f "$DOCS" 2>"$WORK/err" \
| awk '/^\{/ { print; exit }' >"$JSON"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not a scalar finding."
FAILED="$FAILED $CHART"
continue
fi
if [ ! -s "$JSON" ]; then
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::ci/yaml2json produced no JSON for $CHART - the manifests were not re-read, so nothing was checked."
FAILED="$FAILED $CHART"
continue
fi
# --- The comparison --------------------------------------------------------
# Standard library only, and it never decides the exit code itself: it writes
# "VERDICT|detail" lines and the loop below decides, so a verdict can never
# be lost to a swallowed status.
set +e
python3 - "$CHART" "$JSON" >"$WORK/$CHART.out" 2>"$WORK/$CHART.pyerr" <<'PY'
import json, sys
chart, path = sys.argv[1], sys.argv[2]
docs = json.load(open(path)).get("docs") or []
lines = []
def name_of(doc):
meta = doc.get("metadata")
if isinstance(meta, dict) and meta.get("name"):
return str(meta["name"])
return "<unnamed %s>" % doc.get("kind", "object")
def typename(value):
return {bool: "bool", int: "int", float: "float", type(None): "null",
dict: "mapping", list: "list"}.get(type(value), type(value).__name__)
def pod_templates(node, path="$"):
"""Any value of a key named "template" carrying a spec with containers.
Found by walking, so a CronJob's spec.jobTemplate.spec.template needs no
special case and neither does whatever workload kind a chart adds next."""
out = []
if isinstance(node, dict):
for key, value in node.items():
here = "%s.%s" % (path, key)
if (key == "template" and isinstance(value, dict)
and isinstance(value.get("spec"), dict)
and "containers" in value["spec"]):
out.append((here, value))
out.extend(pod_templates(value, here))
elif isinstance(node, list):
for i, value in enumerate(node):
out.extend(pod_templates(value, "%s[%d]" % (path, i)))
return out
def metadatas(node, path="$"):
"""Every metadata mapping anywhere in the render - top level and inside pod
templates both, because labels and annotations exist at both levels."""
out = []
if isinstance(node, dict):
for key, value in node.items():
here = "%s.%s" % (path, key)
if key == "metadata" and isinstance(value, dict):
out.append((here, value))
out.extend(metadatas(value, here))
elif isinstance(node, list):
for i, value in enumerate(node):
out.extend(metadatas(value, "%s[%d]" % (path, i)))
return out
# --- 1. CronJob schedules ---------------------------------------------------
for doc in docs:
if not isinstance(doc, dict) or doc.get("kind") != "CronJob":
continue
where = "CronJob %s .spec.schedule" % name_of(doc)
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
schedule = spec.get("schedule", "<absent>")
if isinstance(schedule, str) and schedule.strip():
lines.append("OK|%s: %s is the string %r" % (chart, where, schedule))
elif schedule == "<absent>":
lines.append("FAIL|%s: %s is missing entirely - a CronJob without a "
"schedule is rejected by the API server" % (chart, where))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. A schedule "
"is operator free text and has to leave the template "
"through | quote; without it YAML re-reads the content and "
"a schedule beginning with a digit survives by luck while "
"one beginning with * or @ does not"
% (chart, where, typename(schedule), schedule))
# --- 2. env values ----------------------------------------------------------
for where, template in pod_templates({"docs": docs}):
spec = template.get("spec") if isinstance(template.get("spec"), dict) else {}
for kind in ("initContainers", "containers"):
for container in spec.get(kind) or []:
if not isinstance(container, dict):
continue
cname = container.get("name", "<unnamed>")
for entry in container.get("env") or []:
if not isinstance(entry, dict) or "value" not in entry:
continue # valueFrom entries carry no literal to check
value = entry["value"]
tag = "env %s of container %s at %s" % (
entry.get("name", "<unnamed>"), cname, where)
if isinstance(value, str):
lines.append("OK|%s: %s is a string" % (chart, tag))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. "
"Kubernetes types env[].value as a string and "
"the API server rejects the whole manifest with "
"\"cannot unmarshal %s into Go struct field "
"EnvVar.value of type string\" - at apply time, "
"not in the render. The fix is | quote at the "
"render site; template-chart carries the form"
% (chart, tag, typename(value), value,
"number" if not isinstance(value, bool) else "bool"))
# --- 3. labels and annotations ----------------------------------------------
for where, meta in metadatas({"docs": docs}):
for field in ("labels", "annotations"):
block = meta.get(field)
if not isinstance(block, dict):
continue
for key, value in block.items():
tag = "%s[%r] at %s" % (field, key, where)
if isinstance(value, str):
lines.append("OK|%s: %s is a string" % (chart, tag))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. "
"Kubernetes types %s as map[string]string, so the "
"API server rejects the object - the render stays "
"green. The fix is | quote at the render site"
% (chart, tag, typename(value), value, field))
for line in lines:
print(line)
PY
RC=$?
set -e
if [ "$RC" -gt 1 ]; then
sed 's/^/ /' "$WORK/$CHART.pyerr"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::the scalar comparison itself failed for $CHART (python exit $RC). Treated as a failure, never as a pass."
FAILED="$FAILED $CHART"
continue
fi
CHART_FAIL=""
CHART_OK=0
while IFS='|' read -r VERDICT DETAIL; do
case "$VERDICT" in
OK)
CHART_OK=$((CHART_OK + 1))
echo " ok $DETAIL"
;;
FAIL)
CHART_FAIL=1
echo " FAIL $DETAIL"
echo "::error title=yaml-scalar $CHART::$DETAIL"
;;
*)
[ -n "$VERDICT" ] && echo " $VERDICT $DETAIL"
;;
esac
done <"$WORK/$CHART.out"
echo "::endgroup::"
TOTAL_ASSERTIONS=$((TOTAL_ASSERTIONS + CHART_OK))
if [ -n "$CHART_FAIL" ]; then
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK |"$'\n'
echo "$CHART: FAILED"
elif [ "$CHART_OK" -gt 0 ]; then
CHECKED="$CHECKED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK |"$'\n'
echo "$CHART: ok ($CHART_OK scalar(s) checked)"
elif [ -n "$HAS_OVERLAY" ]; then
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | 0 |"$'\n'
echo "::error title=yaml-scalar $CHART::$CHART has an overlay at ci/yaml-scalar/$CHART.yaml but its render carried no schedule, no env value and no label or annotation at all, so nothing was checked. That overlay exists only to make such a scalar render; a green tick here would be a tick for a check that ran over nothing."
echo "$CHART: FAILED (overlay present, nothing asserted)"
else
NOT_EXERCISED="$NOT_EXERCISED $CHART"
echo "$CHART: nothing of the three kinds rendered - nothing to check."
fi
done
# --- Report ------------------------------------------------------------------
count() { set -- $1; echo "$#"; }
listing() { set -- $1; [ "$#" -eq 0 ] && { echo "–"; return; }; printf '`%s`' "$1"; shift; for C in "$@"; do printf ', `%s`' "$C"; done; echo; }
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
echo "### YAML-scalar render test (Gitea #11)"
echo
echo "Every \`CronJob.spec.schedule\`, every \`env[].value\` and every label and"
echo "annotation value in a chart's render, held against the type Kubernetes requires"
echo "for it. A value rendered without \`| quote\` either aborts the render with an"
echo "error pointing at the template instead of at the value, or arrives as a bool or"
echo "an integer and is rejected by the API server at apply time - never in the render."
echo
echo "| Result | Count | Charts |"
echo "|---|---|---|"
echo "| Scalars checked | $(count "$CHECKED") | $(listing "$CHECKED") |"
echo "| Nothing of the kind rendered | $(count "$NOT_EXERCISED") | $(listing "$NOT_EXERCISED") |"
echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |"
echo
echo "$TOTAL_ASSERTIONS scalar(s) verified in total."
if [ -n "$SUMMARY_ROWS" ]; then
echo
echo "| Chart | Result | Checked |"
echo "| --- | --- | --- |"
printf '%s' "$SUMMARY_ROWS"
fi
} >>"$SUMMARY"
echo "===================== yaml scalar render test ======================"
echo "checked: $(count "$CHECKED")${CHECKED:+ -$CHECKED}"
echo "not exercised: $(count "$NOT_EXERCISED")${NOT_EXERCISED:+ -$NOT_EXERCISED}"
echo "failed: $(count "$FAILED")${FAILED:+ -$FAILED}"
echo "scalars verified: $TOTAL_ASSERTIONS"
echo "==================================================================="
if [ -n "$FAILED" ]; then
echo "ERROR: a value that has to arrive as a string did not, in:$FAILED" >&2
exit 1
fi
if [ "$TOTAL_ASSERTIONS" -eq 0 ]; then
echo "ERROR: not a single scalar was checked in this run. Either no chart renders a schedule, an env value, a label or an annotation any more, or the overlays under ci/yaml-scalar/ no longer reach their objects. A pass over zero assertions is not a pass." >&2
exit 1
fi
-25
View File
@@ -1,25 +0,0 @@
# Selector-match overlay for paperless-ngx (Gitea #101).
#
# Its one job: make the consume CronJob render, so that the required podAffinity
# it carries is actually held against the StatefulSet's pod labels. The template
# sits behind smbConnection.enabled, false in the chart defaults and in
# ci/paperless-ngx/test-values.yaml, so without this overlay the affinity term
# exists in the repository and in no render - and the test would pass over it.
#
# What it guards: the term selects the StatefulSet pod by a label written in
# ANOTHER template (paperless-server.sfs.yaml). Renaming that label leaves both
# files rendering perfectly while the mover can never be scheduled again, one
# Pending pod per schedule, nothing in the render to show for it.
#
# Kept separate from ci/yaml-scalar/paperless-ngx.yaml although both switch SMB
# on: that one feeds the CronJob a hostile SCHEDULE and hostile env values for a
# different test, and folding the two would make each test's failure depend on
# the other's fixture.
smbConnection:
enabled: true
server: "//smb.ci.local/scans"
secrets:
# Satisfies the required() behind the SMB PersistentVolume's
# nodeStageSecretRef. Nothing is mounted in a render.
smbConnectionSecretRef: op://ci/paperless-smb
-12
View File
@@ -7,15 +7,3 @@ ingress:
# clusterIssuer is strictly required (no default); the Ingress just has to
# apply - there is no cert-manager in the CI cluster.
clusterIssuer: ci
# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD
# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443 - and this application has no login path besides OIDC.
#
# So this is not a workaround for the test: it is the chart's documented escape
# hatch, used for exactly the case values.yaml names. The fixtures are the one
# place in the repository where that case is exercised, and removing this entry
# shows what the cap costs a dependency on a non-standard port.
networkPolicy:
egress:
extraPorts:
- 5556
-12
View File
@@ -6,15 +6,3 @@ secrets:
ingress:
clusterIssuer: ci
domain: vv.ci.local
# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD
# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443.
#
# So this is not a workaround for the test: it is the chart's documented escape
# hatch, used for exactly the case values.yaml names. The fixtures are the one
# place in the repository where that case is exercised, and removing this entry
# shows what the cap costs a dependency on a non-standard port.
networkPolicy:
egress:
extraPorts:
- 5556
-53
View File
@@ -1,53 +0,0 @@
# YAML-scalar overlay for paperless-ngx (Gitea #11).
#
# THIS FILE IS THE TEST CASE the issue asked for, and its first job is to make
# an object exist at all. templates/paperless-consume.cronjob.yaml sits behind
# smbConnection.enabled, which is false in the chart defaults and false in
# ci/paperless-ngx/test-values.yaml - deliberately, because the install-test has
# no SMB CSI driver. So before this overlay NOTHING in the repository rendered
# that CronJob: not lint, not the render tests, not the install-test. That is
# the whole reason an unquoted schedule survived in a shipped chart, and it is
# why the fix comes with a render test rather than with a fixture change.
#
# The values below are hostile on purpose. Against the chart before 13.0.0 this
# overlay does not produce a finding - it produces an aborted render, which this
# test reports as the finding it is:
#
# Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
# error converting YAML to JSON: yaml: line 6: did not find expected
# alphabetic or numeric character
smbConnection:
enabled: true
server: "//smb.ci.local/scans"
# A schedule beginning with "*" - the commonest cron notation there is, and
# the one that YAML reads as an alias indicator. Written here as a quoted
# string, which is exactly how an operator would write it and exactly what
# does NOT help: Helm passes the content through, and without | quote in the
# template the star stands bare in the manifest again.
cronJob: "*/15 * * * *"
secrets:
# The CronJob's SMB volume has a required() secret ref behind it; the
# PersistentVolume names it as nodeStageSecretRef. Nothing is mounted in a
# render, so no fixture Secret is needed - only a value that satisfies the
# required().
smbConnectionSecretRef: op://ci/paperless-smb
paperless:
# The silent half of the genus, on the chart's additive env block: both of
# these are STRINGS in this file and both are schema-valid, and before 13.0.0
# the first one aborted the render while the second arrived in the manifest as
# a YAML bool - which the API server rejects at apply time with "cannot
# unmarshal bool into Go struct field EnvVar.value of type string", so the
# render test is the only place it can be caught cheaply.
env:
- name: PAPERLESS_STAR_VALUE
value: "*never-interpreted*"
- name: PAPERLESS_BOOLEAN_LOOKING_VALUE
value: "true"
# The variable the whole env block was asked for (Gitea #112): the log off
# the NFS-backed RWX data volume and onto the /tmp emptyDir this pod already
# mounts. Harmless in a render, and it keeps the worked example honest by
# being the one an operator will actually copy.
- name: PAPERLESS_LOGGING_DIR
value: "/tmp/paperless-log"
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima
type: application
version: 10.0.0+up11.4.0
version: 9.1.0+up11.4.0
appVersion: "11.4.0"
maintainers:
-46
View File
@@ -108,49 +108,3 @@ cyberchef.env HAS NO EFFECT ON THE APP
files, but the served CyberChef bundle is static and reads none of them —
configuration has to happen in the browser.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8080 only, the single port this pod declares. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}
plus TCP {{ join ", " . }}{{ end }}{{ end }} — a static file server needs nothing more.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,167 +0,0 @@
{{- /*
NetworkPolicy for cyberchef (Gitea #5).
CyberChef is a static web app: nginx serves files, the recipes run in the
visitor's browser, and the pod holds no state and talks to nothing. That makes
this the simplest policy in the repository and a good one to read first.
What it is worth here is almost entirely the two CEILINGS rather than any
source restriction: only 8080 is reachable, and outbound traffic is capped at
DNS. The pod has no reason to open a single outbound connection - it serves
static files - so the egress cap is as tight as the object allows. Were this
image ever to gain a second listener, or were the container ever compromised,
neither could be reached or could reach out.
The ingress direction is NOT restricted by source in the default, and that is
not an oversight: 8080 is the port all three probes address. See the probe note
on the rule below.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because there IS something to restrict, and here the
restriction is close to total: a static file server needs name resolution and
nothing else, so DNS is the only rule. That is as tight as this object can be
made without breaking the pod.
*/}}
- Egress
ingress:
{{- /*
8080 - the only port this pod declares, the one the Service targets, and the
one all three probes address. It is therefore the probe path, and the shape
below is the whole reason networkPolicy.probeSources exists.
*/}}
- ports:
- protocol: TCP
port: 8080
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress (and the
HTTPRoute - the same Traefik serves both here) goes dark the moment this list
is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-78
View File
@@ -125,81 +125,3 @@ httpRoute:
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8080 only - the single port this pod
# declares - and outbound nothing but DNS. CyberChef is a static web app
# whose recipes run in the browser, so the pod has no reason to open an
# outbound connection at all, which makes the egress cap unusually tight.
#
# WHAT IT DOES NOT DO: the 8080 rule is NOT restricted by source in the
# default, because 8080 is the port all three probes address - see
# probeSources below. So this policy is a port and egress ceiling, not
# pod-level isolation. And it is only a boundary where the CNI enforces
# policies; unenforced, it is accepted and filters nothing.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8080, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Nothing is needed here for CyberChef itself. Add 443 only if something
# is put in front of the pod that has to call out.
extraPorts: []
+1 -1
View File
@@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack
type: application
version: 5.0.0+up1.0.0
version: 4.0.0+up1.0.0
# The chart's own version marker, freely chosen and tied to no upstream. The
# stack bundles ten independently versioned projects (nine of them on by
# default), so there is no single "app version" to track here.
-69
View File
@@ -418,72 +418,3 @@ MORE THAN ONE REPLICA IS NOT MEANINGFUL
config volumes, all of them fighting over the same media claims and the same
VPN credentials.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: the nine probed ports — gluetun's health server (9999) and the eight
web interfaces (8080, 9696, 8191, 7878, 8989, 5055, 6767, 5007) — plus
mediathekarr's newznab port (5008), which is RESTRICTED by source because
nothing probes it and its only consumer is Prowlarr in this same pod.
Outbound: DNS, UDP 51820 (the WireGuard tunnel) and TCP 443 (gluetun's
server-list API){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
THE TEN CONTAINERS TALK TO EACH OTHER OVER LOCALHOST, so none of that traffic
is governed by this object at all — they share a network namespace. And
because that namespace belongs to gluetun, everything the *arr containers
send is encapsulated into the tunnel before it leaves the pod: indexer and
tracker traffic needs no egress rule of its own.
IF THIS POD WILL NOT BECOME READY, SUSPECT THE TUNNEL RULE. gluetun's health
server answers 200 only while the tunnel is up, and all three of its probes
address it, so a blocked UDP 51820 shows up as a pod that never goes Ready
rather than as a network error. 51820 is WireGuard's and Mullvad's standard
port; Mullvad also offers 53, 123, 500 and 4500 for obfuscation, and a
release on one of those has to widen the rule in the template —
networkPolicy.egress.extraPorts is TCP only.
AND IF SEERR CANNOT REACH AN IN-CLUSTER SERVICE (Jellyfin, say), two things
have to be true, not one: its port in networkPolicy.egress.extraPorts, and
gluetun.vpn.firewallOutboundSubnets set — gluetun's own firewall blocks
cluster-internal egress before this policy is ever consulted.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,253 +0,0 @@
{{- /*
NetworkPolicy for freesailarr (Gitea #5).
TEN CONTAINERS IN ONE POD, BEHIND A VPN, AND THE POLICY IS SIMPLER THAN THAT
SOUNDS - for one reason worth stating first: the containers talk to each other
over LOCALHOST. They share a network namespace, so none of that traffic ever
reaches a network interface and NO NetworkPolicy has anything to say about it.
Prowlarr reaching Radarr, Recyclarr reaching Sonarr, every one of those hops is
invisible here. What this policy governs is only what enters or leaves the pod.
INBOUND is the eight published web interfaces plus gluetun's health server, and
almost all of them are probe ports - the kubelet opens an HTTP connection to
each container's own interface - so they are one rule on the probe path. The
newznab port of mediathekarr is the exception: nothing probes it, and its only
consumer is Prowlarr in this same pod over localhost, so it is restricted by
source and that costs nothing.
OUTBOUND IS WHERE THIS CHART IS UNLIKE EVERY OTHER ONE HERE, because the pod's
network namespace belongs to gluetun. Every connection the *arr containers make
goes into the WireGuard tunnel and leaves the pod as UDP to the VPN endpoint -
so a NetworkPolicy sees the OUTER packets and almost nothing else. Three rules
cover it: DNS, UDP 51820 for the tunnel itself, and 443 for gluetun's own
server-list API. Indexer and tracker traffic needs no rule of its own; it is
already inside the tunnel.
AND THE TUNNEL RULE IS THE ONE THAT CAN BREAK THIS CHART, which is also why it
is well covered: gluetun's health server returns 200 only while the tunnel is
up, and all three of its probes address it. A blocked UDP 51820 therefore means
a pod that never becomes Ready - visibly, in the install-test, which brings up
a real tunnel. 51820 is WireGuard's standard port and Mullvad's; Mullvad also
offers 53, 123, 500 and 4500 for obfuscation, and a release using one of those
needs networkPolicy.egress.extraPorts. Note that extraPorts is TCP only, so an
alternative UDP port needs the rule here widened instead - said plainly,
because it is a limit of the shared values shape and not an oversight.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed, and for this chart it is unusually meaningful: because the
pod's network namespace belongs to gluetun, the only things that leave it are
the tunnel, DNS and gluetun's control-plane calls. Everything the applications
do is encapsulated. So three narrow rules really do describe the pod's whole
outbound surface - which is rare here.
*/}}
- Egress
ingress:
{{- /*
THE PROBE PATH, nine ports of it. gluetun's health server (9999) plus the eight
web interfaces the kubelet probes directly: qBittorrent (8080), Prowlarr
(9696), FlareSolverr (8191), Radarr (7878), Sonarr (8989), Seerr (5055), Bazarr
(6767) and Mediathekarr (5007). Every one of them is addressed by at least one
httpGet probe, so none of them can be restricted by source without the worker
node's IP.
Recyclarr is absent from this list on purpose: it declares no port and its
probes are exec probes, so there is nothing to allow.
*/}}
- ports:
- protocol: TCP
port: 9999
- protocol: TCP
port: 8080
- protocol: TCP
port: 9696
- protocol: TCP
port: 8191
- protocol: TCP
port: 7878
- protocol: TCP
port: 8989
- protocol: TCP
port: 5055
- protocol: TCP
port: 6767
- protocol: TCP
port: 5007
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule - this chart renders TWO
Ingresses, the main one and qBittorrent's, and both go dark the moment this
list is filled without it.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
{{- /*
MEDIATHEKARR'S NEWZNAB PORT, and the one inbound port here that can be
restricted: no probe addresses it. Its only consumer is Prowlarr, which runs in
THIS pod and reaches it over localhost - traffic no NetworkPolicy sees at all -
so restricting this rule to the release's own namespace plus allowedNamespaces
costs nothing and removes a port from the cluster's reach.
*/}}
- ports:
- protocol: TCP
port: 5008
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
THE WIREGUARD TUNNEL, and the rule this chart cannot live without. Everything
the *arr containers do leaves the pod inside this tunnel, so blocking it
blocks all of them at once - and gluetun's health server, which all three of
its probes address, returns 200 only while the tunnel is up. The pod would
never become Ready.
51820 is WireGuard's standard port and Mullvad's. Mullvad also offers 53, 123,
500 and 4500 for obfuscation; a release using one of those must widen THIS rule,
because networkPolicy.egress.extraPorts is TCP only.
*/}}
- ports:
- protocol: UDP
port: 51820
{{- /*
Outbound HTTPS, for gluetun's own control-plane traffic: it fetches and caches
the provider's server list over the API before the tunnel exists, so this
cannot go through the tunnel. Indexer and tracker traffic does NOT need a rule
here - it is already inside the tunnel by the time it leaves the pod.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-98
View File
@@ -1330,101 +1330,3 @@ mediathekarr:
capabilities:
drop:
- ALL
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# TEN CONTAINERS IN ONE POD, AND THE FIRST THING TO KNOW: they talk to each
# other over LOCALHOST. They share a network namespace, so that traffic never
# touches an interface and no NetworkPolicy sees it. This object governs only
# what enters or leaves the pod.
#
# WHAT IT ALLOWS, inbound: the nine probed ports as one rule (gluetun's
# health server 9999, plus qBittorrent 8080, Prowlarr 9696, FlareSolverr
# 8191, Radarr 7878, Sonarr 8989, Seerr 5055, Bazarr 6767, Mediathekarr
# 5007), and mediathekarr's newznab port 5008 RESTRICTED by source - nothing
# probes it, and its only consumer is Prowlarr over localhost, so restricting
# it costs nothing.
#
# OUTBOUND, and this chart is unlike every other one here: the pod's network
# namespace belongs to gluetun, so everything the *arr containers do is
# encapsulated into the WireGuard tunnel before it leaves. Three rules
# describe the whole outbound surface - DNS, UDP 51820 for the tunnel, and
# 443 for gluetun's server-list API, which cannot go through the tunnel
# because it runs before the tunnel exists. Indexer and tracker traffic needs
# no rule: it is already inside.
#
# THE TUNNEL RULE IS THE ONE THAT CAN BREAK THIS CHART, and it is also the
# best-covered: gluetun's health server returns 200 only while the tunnel is
# up, and all three of its probes address it, so a blocked 51820 means a pod
# that never becomes Ready - visibly, and in the install-test, which brings
# up a real tunnel. 51820 is WireGuard's and Mullvad's standard port; Mullvad
# also offers 53, 123, 500 and 4500 for obfuscation, and a release using one
# of those must widen the rule in the TEMPLATE, because egress.extraPorts is
# TCP only. That is a limit of the shared values shape, said plainly.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the nine probed ports (gluetun's health server and the eight web interfaces), which is every inbound port except mediathekarr's newznab port.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# TCP ONLY - an alternative WireGuard UDP port cannot be added here. The
# usual entry is an in-cluster service Seerr talks to outside the tunnel
# (Jellyfin on 8096), which also needs gluetun.vpn.firewallOutboundSubnets
# set, or gluetun's own firewall blocks it before this policy is reached.
extraPorts: []
+1 -1
View File
@@ -19,7 +19,7 @@ type: application
# offer those as "updates", which is why the garage block in renovate.json5
# carries a versioningTemplate anchored to ^v<major>.<minor>.<patch>$ - same
# shape as the stalwart block, for the same reason.
version: 2.0.0+upv2.4.1
version: 1.1.0+upv2.4.1
appVersion: "v2.4.1"
maintainers:
-60
View File
@@ -204,63 +204,3 @@ TRAPS WORTH KNOWING BEFORE YOU NEED THEM
- THE RPC SECRET IS IN THE PROCESS ENVIRONMENT, not in a file. That is
forced, not chosen: Garage rejects a secret file with any group bit set,
and the kubelet sets one. values.yaml has the mechanism.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound, and the four ports split three ways:
- s3 ({{ $settings.ports.s3 | int64 }}) and web ({{ $settings.ports.web | int64 }}): RESTRICTED BY SOURCE — this namespace,
networkPolicy.allowedNamespaces, and the ingress controller.
- admin ({{ $settings.ports.admin | int64 }}): open inside the cluster, because all three probes address
it. This is the ADMIN API, so read that as a smaller ceiling rather than
a closed door; networkPolicy.probeSources closes the rest.
- rpc ({{ $settings.ports.rpc | int64 }}): this release's own pods only. It has no Service.
Outbound: DNS and TCP {{ $settings.ports.rpc | int64 }} (the cluster bus, outbound as well as in){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }},
plus TCP {{ join ", " . }}{{ end }}{{ end }}.
CHECK YOUR S3 CONSUMERS. Anything in another namespace that talks to this
endpoint on its in-cluster Service address — a backup job, a Zipline storing
uploads, a Stalwart blob store — is blocked until that namespace is in
networkPolicy.allowedNamespaces. Consumers using the published Ingress name
arrive through the controller and are already covered. The failure is an
application-level error with nothing pointing at this object.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
-232
View File
@@ -1,232 +0,0 @@
{{- /*
NetworkPolicy for garage (Gitea #5).
Garage declares four ports and they split three ways, which makes this the
second-clearest illustration of the convention after keycloak:
s3 (3900) the S3 API. The Ingress points at it and no probe touches it,
so it is RESTRICTED by source.
web (3902) static website serving, same treatment as s3.
admin (3903) the port all three probes address - tcpSocket twice and a
httpGet /health - so it stays open inside the cluster. This is
also the admin API, which is the uncomfortable part and is
named in values.yaml: narrowing it means probeSources.
rpc (3901) the node-to-node bus. It has NO Service at all, deliberately
(see garage.service.yaml), and it gets the narrowest rule this
object can express: the pods this policy itself selects.
All four numbers are derived from garage.settings, so moving a port in values
moves the policy with it.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $settings := fromYaml (include "garage.settings" .) }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed and it is narrow, which is the point: a storage node's only
outbound traffic is DNS and its own cluster bus. Not permitted, and named in
values.yaml because it is the plausible surprise: outbound HTTPS. Garage does
not need it for S3 serving, but a deployment that configures remote replication
or an external metrics sink does.
*/}}
- Egress
ingress:
{{- /*
THE S3 API AND THE STATIC WEB PORT, both genuinely restricted by source,
because no probe addresses either of them: this release's own namespace,
whatever allowedNamespaces adds, and the ingress controller.
THE SOURCE LIST IS THE THING TO GET RIGHT HERE. Every consumer of this S3
endpoint that lives in another namespace - a backup job, a Zipline using it for
uploads, a Stalwart blob store - is a pod in that namespace and is BLOCKED
unless the namespace is in allowedNamespaces. Consumers reaching the published
Ingress name instead arrive through the controller and are already covered. A
blocked S3 consumer fails at the application level, with nothing naming this
object.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.s3 | int64 }}
- protocol: TCP
port: {{ $settings.ports.web | int64 }}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- /*
THE ADMIN PORT, and it is the probe path: liveness and startup open a TCP
socket against it and readiness fetches /health from it. So it stays open
inside the cluster by default.
That is worth stating rather than glossing, because this is the ADMIN API: key
management and cluster layout live here. The policy caps it at "reachable from
inside the cluster", not at "closed", and probeSources is what closes the rest -
at the usual price of needing the worker node IPs.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.admin | int64 }}
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
No ingress-controller entry on this rule: nothing should route to the admin API
from outside.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
{{- /*
THE NODE-TO-NODE RPC BUS, restricted to the pods this policy already selects -
a podSelector with no namespaceSelector, which means "these labels, in this
namespace".
This port deliberately has no Service (garage.service.yaml says why: a Service
port would advertise a cluster interface that is not one), so this rule is the
only description of it anywhere in the chart. The matching egress rule below is
what lets a node dial OUT to its peers.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.rpc | int64 }}
from:
- podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
Outbound RPC to the other nodes of this cluster. Needed as well as the inbound
rule: policyTypes lists Egress, so a node dialling its peer is subject to this
policy too, and with only the inbound rule the bus would be permitted in one
direction - a layout that never converges while every node looks healthy.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.rpc | int64 }}
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"description": "true renders the StatefulSet with replicas: 0 and takes precedence over garage.replicas.",
"type": "boolean"
-89
View File
@@ -501,92 +501,3 @@ httpRoute:
# garage.s3.rootDomain - the two still have to be set in step, as they do for
# the Ingress.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS, and Garage's four ports split three ways:
# - s3 (3900) and web (3902): RESTRICTED BY SOURCE to this namespace,
# allowedNamespaces and the ingress controller. No probe touches them,
# which is what makes the restriction possible.
# - admin (3903): open inside the cluster, because all three probes address
# it. This is the ADMIN API - key management and cluster layout - so the
# cap is "reachable from inside the cluster", not "closed". probeSources
# closes the rest.
# - rpc (3901): this release's own pods only. It has no Service at all.
# Outbound: DNS and the rpc bus, nothing else.
#
# THE SOURCE LIST FOR s3 IS THE THING TO GET RIGHT. Every S3 consumer in
# ANOTHER namespace - a backup job, a Zipline storing uploads, a Stalwart
# blob store - is blocked unless that namespace is in allowedNamespaces.
# Consumers that use the published Ingress name arrive through the controller
# and are already covered. A blocked consumer fails at the application level,
# with nothing naming this object.
#
# NOT allowed, and the plausible surprise: outbound HTTPS. Garage does not
# need it to serve S3, but remote replication or an external metrics sink
# does - see egress.extraPorts.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the admin port (garage.ports.admin, 3903 by default), which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# 443 is the entry to add for remote replication or an external metrics
# sink. Nothing is needed for plain S3 serving.
extraPorts: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://gotenberg.dev/img/logo.png
type: application
version: 9.0.0+up8.37.0
version: 8.0.3+up8.37.0
appVersion: "8.37.0"
maintainers:
-87
View File
@@ -80,90 +80,3 @@ REQUESTS TIME OUT AFTER 30 SECONDS
cannot be changed here. This is the ceiling large conversions actually meet:
an 18 MB HTML needs about 22 seconds, and a 25 MB one does not finish inside
the 30. Callers that need longer must split the document instead.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 3000 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }}.
{{- /* Null-safe on purpose: networkPolicy.egress may be erased to null (the
schema permits it), and a bare .egress.remoteAssets then fails the whole
render with "nil pointer evaluating interface {}.remoteAssets" - an
error naming this file while the operator's value appears nowhere in it.
Same rule as issue #99: an erased block means "the default applies", not
a broken render. */}}
{{- if (.Values.networkPolicy.egress | default dict).remoteAssets }}
REMOTE ASSETS ARE ALLOWED IN THIS RELEASE (networkPolicy.egress.remoteAssets:
true), so Chromium may fetch over TCP 80 and 443 to any destination. Know
what that opens: this service is reachable from the whole cluster by default
(3000 is the probe port), so anything that can POST here can now have
Chromium fetch an arbitrary URL — including one inside the cluster. Switch it
back off if nothing in this release renders HTML that references remote
images.
{{- else }}
EGRESS IS DNS ONLY HERE, AND IT COSTS SOMETHING SILENTLY. Gotenberg drives a
headless Chromium, so an egress rule for 80/443 would turn a service the
whole cluster can POST to into a way of fetching arbitrary URLs — which is
why the default is off.
What it costs: in a rendered HTML document, EMBEDDED images are MIME parts
inside the file and convert fine, but REMOTE images (<img src="https://...">)
are fetched by Chromium at render time and are SILENTLY DROPPED. The PDF is
produced and is simply missing them. Nothing fails, nothing is logged here,
no probe notices.
If this release needs them — a paperless-ngx rendering mail bodies that
reference remote pictures is the case — set
networkPolicy:
egress:
remoteAssets: true
and not extraPorts: [80, 443]. The named value says why the ports are open;
a port list cannot.
{{- end }}
THIS CHART HAS NO INGRESS AND ITS CONSUMERS ARE OTHER PODS. While
probeSources is empty the 3000 rule has no source selector; narrowing it
means listing the consumers' namespaces in allowedNamespaces in the same
change, or every conversion stops.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,195 +0,0 @@
{{- /*
NetworkPolicy for gotenberg (Gitea #5).
Gotenberg here is a cluster-internal service: no Ingress, and its consumers are
other pods (paperless-ngx converts mail HTML to PDF through it).
EGRESS IS DNS ONLY BY DEFAULT, AND THAT IS A DELIBERATE, VISIBLE TRADE rather
than an oversight. Gotenberg drives a headless Chromium, so an egress rule for
80 and 443 would let anything that can POST to this service fetch arbitrary
URLs through it - a textbook SSRF pivot, and the pod is reachable from the
whole cluster by default because 3000 is the probe port. Blocking it costs the
remote-asset half of HTML conversion: a converted page keeps its EMBEDDED
content and loses the images it would have FETCHED, which degrades a PDF rather
than failing a pod. The degradation is silent, which is why it is named here, in
values.yaml and in NOTES.txt.
A deployment that needs those images sets networkPolicy.egress.remoteAssets:
true - a named switch rather than a line in extraPorts, so the trade is a
decision someone made and not a port number nobody can place. Its default is
off, because restrictive-by-default is the convention and the deployment is
where reality is accommodated. The long form of what it buys, and of the one
thing about it that was NOT verified, is on the rule itself.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because this is the chart where the cap matters most: a
headless Chromium that can reach the internet turns a service anyone in the
cluster can POST to into an SSRF pivot. DNS is the only rule.
*/}}
- Egress
ingress:
{{- /*
3000 - the only port this pod declares, the one the Service forwards 80 to, and
the one all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 3000
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
NO ingress controller is added here, because this chart renders no Ingress: its
consumers are other pods, and they have to be named in allowedNamespaces.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with $np.egress }}
{{- if .remoteAssets }}
{{- /*
REMOTE ASSETS: the web, on both ports, to any destination. Off by default,
and the switch exists because the cost of leaving it off is invisible while
the cost of removing it is not.
WHAT IT BUYS, measured on the live cluster rather than assumed. paperless-ngx
splits a mail into two steps with two separate endpoints, both
cluster-internal: the .eml goes to TIKA for parsing
(PAPERLESS_TIKA_ENDPOINT) and the resulting HTML comes here for rendering
(PAPERLESS_TIKA_GOTENBERG_ENDPOINT). Of the images in that HTML:
- EMBEDDED images are MIME parts INSIDE the file. Neither Tika nor
Gotenberg needs the network for them, which is why apache-tika stays
DNS-only and why this switch is not needed for a mail that carries its
pictures with it.
- REMOTE images (<img src="https://..."> in the mail body) are fetched by
CHROMIUM, here, at render time. With this switch off they are silently
dropped: the PDF is produced, it is just missing them. Nothing fails,
nothing is logged by this chart, and no probe notices.
NOT CHECKED, and not claimed: whether paperless-ngx re-embeds the images
Tika extracted before handing the HTML over. If it does not, embedded
images would be lost as well - but that would be pre-existing paperless
behaviour and not this policy, so do not read the switch as a fix for it.
WHY IT IS A SWITCH AND NOT THE DEFAULT. Gotenberg drives a headless
Chromium and 3000 is its probe port, so the service is reachable from the
whole cluster by default. Allowing 80/443 outbound therefore turns anything
that can POST here into a way of fetching arbitrary URLs - an SSRF pivot
with the cluster's whole internal address space behind it. That argument is
unchanged by the switch; the switch only makes the trade a NAMED decision
with a default of "no" instead of a line in extraPorts that reads like
nothing in particular.
*/}}
- ports:
- protocol: TCP
port: 80
- protocol: TCP
port: 443
{{- end }}
{{- end }}
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-41
View File
@@ -9,47 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"remoteAssets": {
"description": "Lets Chromium fetch over TCP 80 and 443 to any destination, for the REMOTE images in a rendered HTML document (<img src=\"https://...\">). Off by default: Gotenberg drives a headless Chromium and 3000 is its probe port, so the service is reachable from the whole cluster, and allowing outbound web access turns anything that can POST here into a way of fetching arbitrary URLs. EMBEDDED images are MIME parts inside the file and need no network at all, which is the case this switch is NOT for. With it off, remote images are silently dropped - the PDF is produced and is simply missing them.",
"type": "boolean"
},
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-97
View File
@@ -92,100 +92,3 @@ gotenberg:
limits:
memory: 3Gi
ephemeralStorage: 256Mi
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 3000 only and outbound nothing but DNS.
#
# THE EGRESS CAP IS THE POINT OF THIS ONE. Gotenberg drives a headless
# Chromium, so allowing 80/443 outbound would let anything that can POST to
# this service fetch arbitrary URLs through it - and 3000 is the probe port,
# so the whole cluster can POST to it by default. The cost is the
# remote-asset half of HTML conversion: a converted page keeps its inline
# content and silently loses the images it would have FETCHED. That is a
# degraded PDF, not a dead pod, which is exactly why it is written down here.
# A deployment that needs those images sets egress.remoteAssets: true below -
# a named switch, knowingly, rather than a port pair in extraPorts.
#
# And like every chart here: 3000 is not restricted by source in the default
# (probe port), and a policy is only a boundary where the CNI enforces one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 3000, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
egress:
# REMOTE ASSETS: lets Chromium fetch over TCP 80 and 443, to any
# destination. OFF by default, and both halves of that are deliberate.
#
# WHAT IT BUYS, measured rather than assumed. paperless-ngx splits a mail
# into two steps with two separate cluster-internal endpoints: the .eml goes
# to TIKA for parsing (PAPERLESS_TIKA_ENDPOINT) and the resulting HTML comes
# here for rendering (PAPERLESS_TIKA_GOTENBERG_ENDPOINT). Of the images in
# that HTML:
# - EMBEDDED images are MIME parts INSIDE the file. Neither Tika nor
# Gotenberg needs the network for them - which is why apache-tika stays
# DNS-only, and why a mail that carries its pictures with it needs
# nothing from this switch.
# - REMOTE images (<img src="https://..."> in the body) are fetched by
# CHROMIUM, here, at render time. With this off they are SILENTLY
# DROPPED: the PDF is produced and is simply missing them. Nothing
# fails, nothing is logged by this chart, no probe notices.
#
# NOT CHECKED, and deliberately not claimed: whether paperless-ngx re-embeds
# the images Tika extracted before handing the HTML over. If it does not,
# embedded images would be lost as well - but that would be pre-existing
# paperless behaviour and not this policy. Do not read this switch as a fix
# for it.
#
# WHY IT IS OFF BY DEFAULT. Gotenberg drives a headless Chromium and 3000 is
# its probe port, so the service is reachable from the whole cluster by
# default. Allowing 80/443 outbound therefore turns anything that can POST
# here into a way of fetching arbitrary URLs - an SSRF pivot with the
# cluster's internal address space behind it. Switching this on is a trade,
# and it should be one somebody made on purpose.
remoteAssets: false
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# Use remoteAssets above rather than putting 80 and 443 here: the named
# value says WHY the ports are open, and this list cannot.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Normally empty. Remote assets have their own switch above
# (egress.remoteAssets) and do NOT belong here, because a bare 80/443 in
# this list records no reason. Add a port here only for something neither
# the switch nor the chart covers.
extraPorts: []
+1 -1
View File
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
icon: https://www.home-assistant.io/images/home-assistant-logo.svg
type: application
version: 7.0.0+up2026.8.3
version: 6.2.0+up2026.8.3
appVersion: "2026.8.3"
-52
View File
@@ -372,55 +372,3 @@ env IS APPENDED, AND HOME ASSISTANT WILL NOT READ IT ON ITS OWN
value: a value is stored in the pod spec and readable by anyone who can read
pods in {{ .Release.Namespace }}.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8123 only — anything else this image or an add-on binds is not
reachable. THERE IS NO EGRESS RESTRICTION AT ALL, deliberately: outbound
traffic here is the union of every integration you enable (mDNS and SSDP
discovery, MQTT, a Zigbee or Z-Wave bridge, ESPHome, printers, speakers,
inverters, cloud APIs, NTP), a chart cannot know that set, and a cap would
break integrations one at a time — months apart, each looking like a flaky
device rather than like a policy. An installation that wants one should write
it at release level, against its own integration list.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app="{{ .Release.Name }}-{{ .Chart.Name }}"
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,124 +0,0 @@
{{- /*
NetworkPolicy for homeassistant (Gitea #5).
INGRESS ONLY, AND THE MISSING EGRESS SECTION IS THE DECISION IN THIS FILE.
Home Assistant's outbound traffic is the union of every integration an operator
enables, and that set is unknowable to a chart: mDNS and SSDP discovery on
5353/UDP and 1900/UDP, MQTT on 1883 or 8883, a Zigbee or Z-Wave bridge on
whatever port its firmware chose, ESPHome on 6053, printers, speakers, TVs,
inverters, cloud APIs on 443, an NTP client on 123/UDP. A port cap here would
not protect anything meaningful - the devices are on the same LAN as the node -
and it would break integrations ONE AT A TIME, months apart, each failure
looking like a flaky device rather than like this object. There is no version
of this list that stays correct.
So the rule from valkey applies, in the other direction: policyTypes names only
what there is something to restrict, because an egress rule that has to be
widened until it permits everything makes the object look stricter than it is
while costing real outages to get there. If a deployment wants an egress cap,
it belongs in a release-level policy written against that installation's actual
integrations - not in a chart that cannot see them.
What the Ingress half still buys is real and worth having on its own: only 8123
is reachable, so anything else this image or an add-on binds is not.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects this chart's workload. Note the label: the bare release-and-chart name,
QUOTED, with no -sfs suffix - unlike most charts here. Copying a selector from
a neighbouring chart would match nothing and leave this policy filtering
nothing, which is what ci/run-networkpolicy-test.sh holds it against.
*/}}
podSelector:
matchLabels:
app: "{{ .Release.Name }}-{{ .Chart.Name }}"
policyTypes:
- Ingress
ingress:
{{- /*
8123 - the only port this pod declares, the one the Service forwards 80 to, and
the port all three probes address. The probe path.
No ingress-controller entry: this chart keeps its flexible upstream ingress
style and renders no Ingress of its own (a documented exception in README),
so the sources to name are namespaces, through allowedNamespaces.
*/}}
- ports:
- protocol: TCP
port: 8123
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
{{- end }}
-24
View File
@@ -9,30 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-53
View File
@@ -235,56 +235,3 @@ core:
# kubectl rollout restart statefulset <release>-homeassistant-sfs
secrets:
configSecretRef: null
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8123 only. THERE IS NO EGRESS SECTION, and
# that is the decision in this chart rather than an omission.
#
# Home Assistant's outbound traffic is the union of every integration enabled
# in a release: mDNS/SSDP discovery (5353/UDP, 1900/UDP), MQTT (1883/8883), a
# Zigbee or Z-Wave bridge on whatever port its firmware chose, ESPHome (6053),
# printers, speakers, TVs, inverters, cloud APIs on 443, NTP on 123/UDP. A
# chart cannot know that set, a cap would protect little - the devices sit on
# the node's own LAN - and it would break integrations one at a time, months
# apart, each looking like a flaky device rather than like a policy. So
# policyTypes names Ingress only, for the same reason valkey names Ingress
# only: a rule that has to be widened until it permits everything makes the
# object look stricter than it is.
#
# An installation that wants an egress cap should write one against its own
# integrations, at release level, where they are visible.
#
# Inbound 8123 is not restricted by source in the default (probe port), and a
# policy is only a boundary where the CNI enforces one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8123, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# procedure lives in NOTES.txt and on the wiki page instead of in a
# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
# merging an app-version PR that changes the minor.
version: 2.0.0+up26.8.0
version: 1.3.0+up26.8.0
appVersion: "26.8.0"
maintainers:
-77
View File
@@ -363,80 +363,3 @@ WHAT THIS CHART DOES NOT RENDER
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound, and all three ports differ:
- http ({{ $settings.ports.http | int64 }}): RESTRICTED BY SOURCE — this namespace, anything in
networkPolicy.allowedNamespaces, and the ingress controller. No probe uses
this port, which is what makes the restriction possible.
- management ({{ $settings.ports.management | int64 }}): open inside the cluster, because all three probes
address it. Its health and metrics surface is UNAUTHENTICATED, and before
this object the only thing protecting it was that no Ingress pointed at
it. Narrow it with networkPolicy.probeSources.
- jgroups ({{ $settings.ports.jgroups | int64 }}): this release's own pods only.
Outbound: DNS, TCP {{ $settings.db.port | int64 }} (the CONFIGURED database port — this chart derives it
rather than guessing), {{ $settings.ports.jgroups | int64 }} (the cluster bus, outbound as well as in) and 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }},
plus TCP {{ join ", " . }}{{ end }}{{ end }}.
TWO THINGS TO CHECK AFTER THE FIRST UPGRADE, because nothing else will.
1. LOG IN. This release is the cluster's identity provider, and the http rule
is the only source-restricted inbound rule in the repository. If the
ingress-controller source is wrong for this cluster, every login
everywhere stops — and no part of CI can see it, because no ingress
controller runs there and the probes use the management port, so the pod
becomes Ready either way. The default was measured against the live
cluster (DaemonSet rke2-traefik in kube-system, which serves the Gateway
API here too), but measured labels are not proven traffic.
2. CHECK YOUR CONSUMERS' ISSUER URLS. An application that reaches Keycloak on
its in-cluster Service address rather than its public URL is a pod in
another namespace, and the http rule blocks it. Those namespaces go into
networkPolicy.allowedNamespaces. Consumers using the public issuer URL
arrive through Traefik and are already covered.
AND MAIL IS NOT ALLOWED. Keycloak's SMTP server is configured per realm,
which this chart never sees, so verification and password-reset mail needs its
port (587 or 465) in networkPolicy.egress.extraPorts. The symptom otherwise is
mail that is never delivered, with a healthy pod behind it.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,281 +0,0 @@
{{- /*
NetworkPolicy for keycloak (Gitea #5).
THE RICHEST POLICY OF THIS ROLLOUT, AND THE ONE WITH THE LARGEST BLAST RADIUS.
Read both halves of that before changing anything here.
Keycloak is the only chart in the repository whose three ports fall into three
DIFFERENT classes, so it is the one place the convention is visible in full:
http (8080) NOT addressed by any probe, so it CAN be restricted by
source - and it is. Own namespace, allowedNamespaces, and
the ingress controller. This is the only real inbound
restriction in the sweep, and it is possible only because
the probes use a different port.
management (9000) the port all three probes address, so it stays open by
default. That is unfortunate here specifically: 9000
carries the health and metrics surface UNAUTHENTICATED,
and "no Ingress" was its only protection before this
object existed. The policy improves it from "anything that
can route to the pod" to "anything inside the cluster",
and networkPolicy.probeSources is what closes the rest -
at the price of needing the worker node IPs.
jgroups (7800) the cluster bus between this release's own replicas.
Restricted to the pods this policy itself selects, which
is the narrowest rule this object can express.
All three port numbers and the database port are DERIVED from the chart's own
values through keycloak.settings, not written down twice: an operator who moves
keycloak.ports.http moves the policy with it. The database port is
keycloak.db.port, so unlike most charts in this sweep the egress rule here is
the CONFIGURED port and not a guess.
THE BLAST RADIUS, stated plainly because it is the largest in this issue: this
release is the cluster's identity provider. If the ingress-controller source
below is wrong, every login everywhere breaks, and NOTHING IN CI CAN CATCH IT -
no ingress controller runs in the install-test, and the probes use 9000, so the
pod becomes Ready either way. The source was measured on the live cluster
(DaemonSet rke2-traefik in kube-system, which also serves the Gateway API
here), but measured labels are not proven traffic. After the first upgrade,
verify with an actual login rather than with kubectl.
AND THE SECOND TRAP, which is the same shape: a consumer that reaches Keycloak
on its IN-CLUSTER Service address rather than through its public URL is a pod
in another namespace, and the http rule blocks it. Those namespaces belong in
allowedNamespaces. Consumers that use the public issuer URL arrive through
Traefik and are already covered.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $settings := fromYaml (include "keycloak.settings" .) }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods, and the same label the PodDisruptionBudget and
the two Services use. ci/run-networkpolicy-test.sh fails if it matches no pod
spec of the same render.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because there is a great deal to restrict and almost all of it
is known: the database on its configured port, the cluster bus, and HTTPS. What
is NOT permitted and is the likely first surprise: SMTP. Keycloak sends mail
(verification, password reset) through a server configured per realm, which
this chart cannot see - so it needs networkPolicy.egress.extraPorts.
*/}}
- Egress
ingress:
{{- /*
THE HTTP PORT, AND THE ONE GENUINELY RESTRICTED INBOUND RULE IN THIS SWEEP.
It can be restricted because no probe addresses it - the probes use the
management port - so there is no kubelet traffic to accommodate and no need for
probeSources here. Allowed: this release's own namespace, whatever
allowedNamespaces adds, and the ingress controller.
Both selectors of the controller entry sit in ONE list item, which makes them
an AND: those pods in that namespace. Split into two items they would be an OR
and would open Keycloak's login endpoint to every pod in kube-system.
If this rule is wrong, every login in the cluster breaks and no part of CI will
say so. See the header.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.http | int64 }}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- /*
THE MANAGEMENT PORT - health, readiness and metrics, and all three probes
address it. So it is the probe path and stays open by default.
That is worth naming rather than glossing: this surface is UNAUTHENTICATED, and
before this object existed its only protection was that no Ingress pointed at
it. What this policy adds is a smaller ceiling, not a closed door - the port is
reachable from inside the cluster. Closing it means listing the worker node IPs
in networkPolicy.probeSources, with the usual cost of getting them wrong.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.management | int64 }}
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
No ingress-controller entry on this rule: nothing routes to the management port
from outside, and it should stay that way.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
{{- /*
THE CLUSTER BUS between this release's own replicas, and the narrowest rule
this object can express: a podSelector with no namespaceSelector, which means
"pods with these labels IN THIS NAMESPACE" - i.e. exactly the pods this policy
already selects.
Infinispan discovery here is jdbc-ping (see values.yaml), so no multicast and
no extra port is involved; the peers find each other through the database and
then connect on this port. The matching egress rule below is what lets them
dial OUT - without it the cluster would form in one direction only.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.jgroups | int64 }}
from:
- podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
The external PostgreSQL, on THE CONFIGURED PORT - keycloak.db.port, derived
through keycloak.settings. This chart is the exception in the sweep: its
database port is a value rather than a field inside an opaque secret, so this
rule follows the configuration instead of guessing at 5432.
It also carries Infinispan's jdbc-ping discovery, which is why a blocked
database here costs the cluster as well as the data.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.db.port | int64 }}
{{- /*
Outbound cluster bus. Needed as well as the ingress rule above: policyTypes
lists Egress, so a replica dialling its peer is subject to this policy too, and
without this rule the bus would be permitted inbound and blocked outbound - a
cluster that never forms, with two nodes that each look healthy.
*/}}
- ports:
- protocol: TCP
port: {{ $settings.ports.jgroups | int64 }}
{{- /*
Outbound HTTPS. Keycloak needs it for identity brokering and for external
identity-provider metadata, both configured inside REALMS this chart never
sees - so the port has to be permitted here or a federated login fails with no
hint pointing at this object. Also covers an outbound webhook or a remote JWKS.
Ports only: these are hostnames.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"description": "true renders the Deployment with replicas: 0 and takes precedence over keycloak.replicas. Also the first half of the minor-upgrade procedure, because Keycloak supports a rolling update only inside a minor line.",
"type": "boolean"
-98
View File
@@ -884,101 +884,3 @@ httpRoute:
# Empty means [ingress.admin.domain]. Required (via that value) while
# ingress.admin.enabled is true and httpRoute.enabled is true.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# THE RICHEST POLICY IN THIS REPOSITORY, AND THE ONE WITH THE LARGEST BLAST
# RADIUS. Both halves matter.
#
# WHAT IT ALLOWS, inbound, and the three ports are three different cases:
# - http (8080): RESTRICTED BY SOURCE to this namespace, allowedNamespaces
# and the ingress controller. The only real inbound restriction in the
# whole sweep, and possible only because no probe uses this port.
# - management (9000): open inside the cluster, because all three probes
# address it. This is the port whose health and metrics surface is
# UNAUTHENTICATED; before this object its only protection was that no
# Ingress pointed at it. probeSources closes the rest.
# - jgroups (7800): this release's own pods only.
# Outbound: DNS, the CONFIGURED database port (keycloak.db.port - this chart
# does not have to guess), the cluster bus, and 443 for identity brokering.
#
# THE BLAST RADIUS: this release is the cluster's identity provider. If
# ingressController below is wrong, EVERY LOGIN EVERYWHERE BREAKS, and no
# part of CI can catch it - no ingress controller runs in the install-test,
# and the probes use 9000 so the pod goes Ready either way. The source was
# measured on the live cluster, but measured labels are not proven traffic:
# after the first upgrade, verify with a real login.
#
# AND THE SECOND TRAP: a consumer that reaches Keycloak on its IN-CLUSTER
# Service address instead of its public URL is a pod in another namespace,
# and the http rule blocks it. Put those namespaces in allowedNamespaces.
# Consumers using the public issuer URL come through Traefik and are covered.
#
# NOT allowed, and the likely first surprise: SMTP. Mail is configured per
# REALM, which this chart never sees - see egress.extraPorts.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the management port (keycloak.ports.management, 9000 by default), which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# SMTP is the entry this chart needs most: 587 or 465, for the mail server
# configured inside a realm. Also an LDAP user federation (389 or 636), or
# a database that is somehow not on keycloak.db.port.
extraPorts: []
+1 -1
View File
@@ -3,7 +3,7 @@ name: matrix-synapse
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
type: application
version: 8.0.0+upv1.159.0
version: 7.2.0+upv1.159.0
appVersion: "v1.159.0"
maintainers:
- name: jklein
-59
View File
@@ -396,62 +396,3 @@ matrix.env IS APPENDED, NOT MERGED
{{ .Release.Namespace }}. Note that Synapse takes its real configuration from
homeserver.yaml, not from the environment.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — TWO OBJECTS, ONE PER WORKLOAD
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects the Synapse pods and
{{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy the delegation nginx. They are
separate because their needs are opposite: Synapse federates and must be able
to reach any homeserver on the internet, while the delegation server returns
two static JSON documents and calls nobody. One policy over both pods would
have to permit the union — handing full federation reach and the database port
to an nginx that serves a constant.
Synapse: inbound TCP 8008 only; outbound DNS, TCP 5432 (Postgres), 443 and
8448 (federation, remote media, push gateways, identity servers){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP
{{ join ", " . }}{{ end }}{{ end }}.
Delegation nginx: inbound TCP 8080 only; outbound DNS and nothing else —
networkPolicy.egress.extraPorts deliberately does not widen it.
Three limits, none of them visible in the objects themselves:
- FEDERATION CANNOT BE NARROWED. 443 and 8448 go to ANY destination, because
a federating homeserver may contact any other homeserver there is, and a
NetworkPolicy matches CIDRs and selectors but never hostnames — with this
cluster's CNI (Canal: Flannel plus Calico) there is no FQDN rule either.
What the cap still buys is real: Synapse can open NO other outbound port,
so a compromised instance reaches no other database, no SSH and no mail
server in the cluster.
- 5432 IS THE STANDARD PORT, NOT YOUR CONFIGURED ONE. Synapse's Postgres
connection lives inside secrets.configSecretRef, which this chart never
parses. A database elsewhere is blocked — put its port in
networkPolicy.egress.extraPorts. The same applies to a TURN server for
voice, or to SMTP if this release sends mail.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATHS ARE RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node a pod lands on, its probes are dropped and THE POD
NEVER BECOMES READY — the rollout times out and the symptom looks like a
broken application.
AND CHECK FEDERATION AFTER THIS CHANGE, not just the web UI. Inbound
federation reaches Synapse through the Ingress, and every remote homeserver
that wants to federate fetches /.well-known/matrix from the delegation
server — so a wrong ingressController here stops federation and stops it
being discoverable, and that failure appears on OTHER people's servers
before it appears on yours.
{{- else }}
- BOTH PROBE PORTS ARE OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so neither rule carries a source selector and any pod the CNI routes
to can reach them. Not from the internet — neither port has a NodePort —
but this is NOT pod-level isolation. Narrowing it means listing the worker
node IPs, because the kubelet probes from the node's own address and the
chart cannot know it; a wrong guess leaves the pod permanently un-Ready.
{{- end }}
Switch both off with networkPolicy.enabled: false. And remember that a policy
is only a boundary where the CNI enforces one — unenforced, both objects are
accepted and filter nothing, so everything reads as configured.
{{- end }}
@@ -1,269 +0,0 @@
{{- /*
Two NetworkPolicies, one per workload (Gitea #5).
THIS CHART RENDERS TWO POD TEMPLATES AND THEY NEED DIFFERENT RULES, which is
why this file is written out rather than generated from one shape like most of
the sweep. A single policy selecting both would have to be the UNION of what
each needs - Synapse's federation egress granted to the delegation server, and
the delegation server's ports granted to Synapse - and a policy that is the
union of two workloads' needs is strictly weaker than two policies, while
looking like one decision.
<release>-<chart>-networkpolicy the Synapse StatefulSet
<release>-<chart>-delegation-networkpolicy the delegation nginx Deployment
The split matters most on egress. Synapse federates: it opens connections to
other people's homeservers, to push gateways and to identity servers, so its
egress rule is necessarily wide. The delegation server serves two static JSON
documents under /.well-known/matrix and calls NOTHING - so its egress is DNS
and nothing else, which is as tight as this object gets. Merging them would
have handed Synapse's reach to an nginx whose entire job is to return a
constant.
BOTH WORKLOADS SIT BEHIND THE SAME INGRESS, on different paths (/ to Synapse,
/.well-known/matrix to the delegation server), so both carry the ingress
controller in their restricted branch - and both would go dark together if it
were wrong.
AND BOTH PROBE PORTS STAY OPEN BY DEFAULT, because all three probes of each
workload address its own HTTP port. The kubelet probes from the IP of the
worker node running the pod, which no chart can know; narrowing is
networkPolicy.probeSources, and it applies to both policies at once.
WHAT THESE POLICIES DO NOT DO:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and never hostnames, so "only the homeservers we federate with"
cannot be expressed - and with Canal (Flannel plus Calico) there is no FQDN
rule to reach for. Federation therefore means 443 and 8448 to any address,
which is the honest description: Synapse may contact any homeserver on the
internet, because that is what federation is.
- THE DATABASE PORT IS A GUESS. Synapse's Postgres connection lives inside
secrets.configSecretRef, a 1Password item this chart never parses, so 5432
is the standard port and not the configured one.
- A POLICY IS ONLY A BOUNDARY WHERE THE CNI ENFORCES ONE.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Synapse StatefulSet's pods - NOT the delegation Deployment's,
which has its own policy below. ci/run-networkpolicy-test.sh reports which pod
templates each selector matches, so a label renamed in one of the two
workloads shows up as a finding rather than as a policy that filters nothing.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
- Egress
ingress:
{{- /*
8008 - Synapse's client and federation API, the port the Service forwards 80
to, and the port all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 8008
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's
probes come from the IP of the worker node running the pod, so that
address has to be in the list or the pod never becomes Ready - and a pod
that is never Ready reads as a broken application rather than as a policy.
MIND THE FEDERATION SIDE OF THIS RULE. Inbound federation traffic arrives
through the Ingress, so the controller entry below is what keeps other
homeservers able to reach this one. Restricting this rule without it does
not merely hide a web UI: it stops federation inbound, which looks like
other servers being unreachable rather than like a local policy.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this
needs no labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these
labels IN that namespace. Written as two list entries it would be an OR,
and would open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to
guess the worker node CIDRs, and a wrong guess blocks every kubelet probe:
the pod stays un-Ready, the rollout times out, and the cause looks like
the application. An empty "from: []" would be worse than either - it
allows everything too, but while reading as a restriction.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in the
pod is a trap and not a feature. Synapse needs it more than most - every
federated server is resolved by name, including its SRV and .well-known
delegation lookups.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
PostgreSQL on the STANDARD port. The connection lives inside
secrets.configSecretRef, a 1Password item this chart never parses, so this
is the conventional port and not the configured one - a Postgres elsewhere
is blocked and belongs in networkPolicy.egress.extraPorts.
*/}}
- ports:
- protocol: TCP
port: 5432
{{- /*
FEDERATION, and this is the rule that cannot be narrowed. 443 and 8448 to
ANY destination: 8448 is the Matrix federation port, 443 is where a
delegating homeserver puts its federation API instead, and both are also
what Synapse uses to fetch remote media, to reach a push gateway and to
reach an identity server.
"Any destination" is not laziness here, it is the specification: a
federating homeserver may contact any other homeserver on the internet, and
this object cannot match a hostname in any case. What the cap still buys is
real - Synapse cannot open a connection on any OTHER port, so a compromised
instance reaches no database, no SSH and no mail server elsewhere in the
cluster.
*/}}
- ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 8448
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. The operator's escape hatch
from the egress cap, and the only alternative to switching the whole policy
off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy
spec:
{{- /* Selects the delegation nginx Deployment's pods only. */}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx-deployment
policyTypes:
- Ingress
- Egress
ingress:
{{- /*
8080 - the only port this pod declares, the port the delegation Service
forwards 80 to, and the port all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 8080
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources - the same value
governs both policies in this chart. The kubelet probes from the node's
own IP, so it has to be in the list or this pod never becomes Ready.
AND THE CONSUMERS OF THIS ONE ARE OTHER PEOPLE'S HOMESERVERS. The
delegation documents under /.well-known/matrix are fetched by every remote
server that wants to federate with this one, through the Ingress. Restrict
this rule without the controller entry and federation stops being
discoverable - a failure that shows up on OTHER servers, not here.
*/}}
from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /* Open inside the cluster, as above - the probe path. */}}
{{- end }}
egress:
{{- /*
DNS AND NOTHING ELSE, and this is the tightest egress rule in the chart on
purpose. This nginx serves two static JSON documents and calls nobody; it
does not touch the database, it does not federate, it has no upstream. Even
DNS is only here because the container resolves at start.
This is exactly what the two-policy split buys. Folded into Synapse's
policy, this pod would have been permitted 443, 8448 and 5432 - full
federation reach and the database - for a workload whose entire job is to
return a constant. networkPolicy.egress.extraPorts deliberately does NOT
apply here: an entry meant for Synapse's database has no business widening
this.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-96
View File
@@ -285,99 +285,3 @@ httpRoute:
# A chart-global hostname list covering both would erase that distinction.
# Set this only when the server name itself has to move.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. TWO objects, one per workload - see
# the template for why they are not one. The reference implementation with the
# full reasoning is template-chart; the convention is in README, "Chart style
# guidelines".
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THESE POLICIES ALLOW. Synapse: inbound 8008 only, outbound DNS plus TCP
# 5432 (Postgres), 443 and 8448 (federation, remote media, push gateways,
# identity servers). The delegation nginx: inbound 8080 only, outbound DNS and
# NOTHING else - it serves two static JSON documents and calls nobody.
#
# THAT ASYMMETRY IS THE REASON THERE ARE TWO. One policy selecting both pods
# would have to permit the union of what each needs, which would hand
# Synapse's full federation reach and its database port to an nginx whose
# whole job is to return a constant - and it would look like one decision
# rather than a widening.
#
# WHAT THEY DO NOT DO: federation cannot be narrowed. 443 and 8448 go to ANY
# destination, because a federating homeserver may contact any other
# homeserver on the internet and this object matches CIDRs and selectors but
# never hostnames. And 5432 is the STANDARD Postgres port, not the configured
# one - the connection lives inside secrets.configSecretRef, which this chart
# never parses.
#
# Both probe ports stay open inside the cluster by default (see probeSources),
# and a policy is only a boundary where the CNI enforces one.
enabled: true
# Namespaces whose pods may reach either workload, in addition to the
# release's own namespace, which is always allowed. Empty means: own namespace
# only. Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on
# every namespace itself, so no labelling work is needed - and no typo
# protection either: a name that does not exist matches nothing and is
# silently ineffective. An entry opens the port to EVERY pod in that
# namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the two probe ports (Synapse's 8008 and the
# delegation server's 8080). ONE VALUE GOVERNS BOTH POLICIES.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry both rules are rendered WITHOUT
# a "from" selector, so the ports are reachable from anywhere the CNI routes
# to - inside the cluster, since neither carries a NodePort.
#
# The kubelet's probes originate from the IP of the WORKER NODE running the
# pod, an address this chart cannot know and must not guess: a CIDR that
# excludes the real node blocks every probe, the pod never becomes Ready, the
# rollout times out, and the symptom reads as a broken application rather
# than as a policy.
#
# AND MIND WHAT ELSE THESE TWO PORTS CARRY. Inbound federation arrives at
# 8008 through the Ingress, and every remote homeserver that wants to
# federate fetches /.well-known/matrix from 8080. Narrowing this list without
# ingressController below therefore does not merely hide a web UI: it stops
# federation, inbound and discoverable, and the failure shows up on OTHER
# people's servers before it shows up here.
probeSources: []
# The ingress controller allowed to reach both ports once probeSources
# restricts them. Rendered only while ingress.enabled or httpRoute.enabled is
# true, and only in the restricted branch - with the default (open) rules the
# controller reaches both ports anyway.
#
# The two keys become ONE "from" entry and are therefore an AND: pods carrying
# podLabels IN namespace. That is the point of having them here - the
# controller's namespace in allowedNamespaces would open both ports to every
# pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports FOR SYNAPSE ONLY. These deliberately do NOT
# widen the delegation server's policy: an entry meant for Synapse's
# database has no business reaching a workload that serves a constant.
#
# DNS is always allowed and has no switch - a knob whose only other setting
# breaks every name lookup is a trap, not a feature, and Synapse resolves a
# name for every server it federates with.
#
# PORTS, NOT DESTINATIONS: a NetworkPolicy matches CIDRs and selectors and
# never hostnames, so each entry permits its port to ANY destination.
#
# The usual entries here: a Postgres that is not on 5432, a TURN/coturn
# server for voice (3478 and its relay range), or SMTP (587 or 465) if this
# release sends mail - Synapse's mail configuration lives inside
# secrets.configSecretRef, which this chart never reads.
extraPorts: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://avatars.githubusercontent.com/u/988985
type: application
version: 5.0.0+up1.47.2
version: 4.1.4+up1.47.2
appVersion: "1.47.2"
maintainers:
-54
View File
@@ -138,57 +138,3 @@ server.env IS APPENDED TO WHAT THE CHART SETS
in clear text, readable by everyone who can read pods in {{ .Release.Namespace }}; anything
sensitive belongs in valueFrom.secretKeyRef.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: the proxy port (TCP {{ .Values.server.port }}, open — it is published as NodePort
{{ .Values.server.outwardPort }} and the senders are players on the internet) and the API port
(TCP {{ .Values.server.apiPort }}, the probe path). Outbound: DNS plus the backend ports taken from
the routing table itself — TCP {{ $ports := list (int .Values.server.backendPort) }}{{ range .Values.mappings }}{{ if .port }}{{ $ports = append $ports (int .port) }}{{ end }}{{ end }}{{ join ", " ($ports | uniq) }}{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
THE BACKEND RULE IS BY PORT, NOT BY DESTINATION. mappings name hosts, and
this object cannot match a hostname — so the rule permits those ports to any
address, which is wider than your routing table. Adding a backend on a
non-standard port needs no change here: the policy reads the same value the
router does.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,184 +0,0 @@
{{- /*
NetworkPolicy for minecraft-router (Gitea #5).
The router is the one chart here whose whole job is to forward traffic to OTHER
releases, so its egress rule is the interesting one and it is DERIVED from the
routing table rather than guessed: server.backendPort, plus the per-mapping
"port" override for every entry of mappings that has one. Add a backend on a
non-standard port and the policy follows it, because both come from the same
value.
What it does NOT express is the destination. The mappings name hosts
(internalUrl), and a NetworkPolicy matches CIDRs and selectors and never
hostnames - so the rule permits those PORTS to any address, not those services.
A backend in another namespace is reachable through this rule; so is anything
else listening on 25565.
Inbound splits in two: the proxy port is a NodePort and faces the internet, the
API port is the probe path.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $backendPorts := list (int .Values.server.backendPort) }}
{{- range .Values.mappings }}
{{- if .port }}{{- $backendPorts = append $backendPorts (int .port) }}{{- end }}
{{- end }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the Deployment's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
policyTypes:
- Ingress
{{- /*
Egress is listed because the router's outbound traffic is exactly known for
once - DNS, and the backend ports from its own routing table. That makes this
one of the few egress caps in the sweep that is derived rather than guessed.
*/}}
- Egress
ingress:
{{- /*
THE PROXY PORT, and deliberately open with no "from" at all: it is published
as a NodePort (server.outwardPort) and the senders are Minecraft clients on the
internet. After NodePort masquerading the source address is a cluster node
anyway, so a source selector here could only ever be a list of node IPs - which
would be a restriction in appearance and a maintenance trap in fact.
NOTE THE NUMBER: this is the POD port (server.port), not server.outwardPort.
The NodePort is translated by kube-proxy before the packet reaches the pod, so
writing 30565 here would match nothing while looking strict - and being the
only rule for this port, it would block every player instead.
ci/run-networkpolicy-test.sh asserts exactly this.
*/}}
- ports:
- protocol: TCP
port: {{ .Values.server.port | int64 }}
{{- /*
THE API PORT - the router's own HTTP interface, and the port all three probes
address. So it is the probe path and stays open inside the cluster by default.
No ingress-controller entry: this chart renders no Ingress, and nothing should
route to the router's API from outside.
*/}}
- ports:
- protocol: TCP
port: {{ .Values.server.apiPort | int64 }}
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
THE BACKENDS, by port, derived from the routing table: server.backendPort plus
the "port" override of every mapping that sets one. So a backend on a
non-standard port is covered automatically, and there is no second list to keep
in step with mappings.
Ports and not destinations: the mappings name hosts (internalUrl) and this
object cannot match a hostname, so this rule permits those ports to ANY
address. That is wider than the routing table and is the honest description of
what the object can do.
*/}}
- ports:
{{- range $backendPorts | uniq }}
- protocol: TCP
port: {{ . }}
{{- end }}
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-37
View File
@@ -9,43 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-67
View File
@@ -150,70 +150,3 @@ server:
# name: "some-configmap"
# key: "someKey"
env: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound the proxy port (server.port, OPEN - it is
# a NodePort and the senders are players on the internet) and the API port
# (the probe path, open inside the cluster). Outbound: DNS plus the backend
# ports DERIVED from the routing table - server.backendPort and the "port"
# override of every mapping that sets one.
#
# So this is one of the few egress caps here that follows the configuration
# instead of guessing at it: add a backend on a non-standard port and the
# policy moves with it.
#
# WHAT IT DOES NOT DO: the rule permits those PORTS to any destination, not
# those SERVICES. mappings name hosts (internalUrl), and a NetworkPolicy
# matches CIDRs and selectors but never hostnames - so anything else
# listening on a backend port is reachable through this rule too.
#
# And the proxy port carries the POD port, not server.outwardPort: a
# NetworkPolicy is evaluated after kube-proxy has translated the NodePort, so
# the NodePort number would match nothing while looking strict.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the API port (server.apiPort), which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Normally empty: the backend ports already come from mappings. Add a port
# only for something outside the routing table - a status API, for
# instance.
extraPorts: []
+1 -1
View File
@@ -23,7 +23,7 @@ type: application
# continuously, so a pod restart could pull a different image than the last one
# without anything being recorded anywhere. The dated tags are immutable, so the
# image is now pinned by the chart version like every other chart in this repo.
version: 7.0.0+up2026.8.2.java25
version: 6.0.1+up2026.8.2.java25
appVersion: "2026.8.2-java25"
maintainers:
-62
View File
@@ -364,65 +364,3 @@ server env IS APPENDED, NOT MERGED
everyone who can read pods in {{ .Release.Namespace }}; anything sensitive belongs in
valueFrom.secretKeyRef.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: the game port (TCP {{ .Values.server.port }}){{ if eq .Values.server.service.type "NodePort" }} — OPEN, because the Service is a
NodePort and the senders are players on the internet{{ else }} — RESTRICTED to this namespace
and networkPolicy.allowedNamespaces, because the Service is {{ .Values.server.service.type }} and the only
path to it is in-cluster{{ end }}; RCON (TCP {{ .Values.server.rcon.port }}) and metrics (TCP 19565), both always
restricted by source. Outbound: DNS and TCP 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
THIS POLICY IS UNUSUALLY TIGHT, and the reason is that all three probes are
exec probes: they run inside the container and put no packet on the network,
so no port has to stay open for the kubelet and networkPolicy.probeSources is
not needed at all.
{{- if not (eq .Values.server.service.type "NodePort") }}
SO CHECK ONE THING: players reach this server through minecraft-router, which
runs in ITS OWN namespace. That namespace must be in
networkPolicy.allowedNamespaces, or the server is unreachable and the symptom
is a connection timeout in a game client with a perfectly healthy pod behind
it. The same goes for a Prometheus scraping port 19565 from elsewhere.
{{- end }}
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,210 +0,0 @@
{{- /*
NetworkPolicy for minecraft-server (Gitea #5).
THIS IS THE CHART WHERE THE POLICY GETS GENUINELY TIGHT, and the reason is its
probes: all three are exec probes. An exec probe runs inside the container
through the kubelet's runtime and puts NO packet on the network, so there is no
kubelet traffic to accommodate on any port - which means every port here can be
restricted by source, with no probeSources needed at all. valkey is tight for
the same reason.
The one rule that cannot be fixed is the game port, and it depends on a value:
server.service.type == NodePort players arrive from the internet, already
masqueraded, so the rule must be OPEN.
anything else (the default) the only reachable path is in-cluster, i.e.
minecraft-router, so the rule is RESTRICTED
to this namespace plus allowedNamespaces -
and the router's namespace has to be in that
list.
Both branches are rendered from the same value the Service reads, so they cannot
disagree. The RCON and metrics ports are restricted in either case.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed because there is something real to restrict: a Minecraft
server needs DNS and HTTPS and nothing else in the normal case. Mod packs that
call out on other ports are the exception, and extraPorts is for them.
*/}}
- Egress
ingress:
{{- if eq .Values.server.service.type "NodePort" }}
{{- /*
THE GAME PORT, NODEPORT BRANCH (server.service.type is NodePort). Open with no
"from": the senders are Minecraft clients on the internet and, after NodePort
masquerading, the source address is a cluster node anyway.
THE NUMBER IS THE POD PORT (server.port), not server.service.outwardPort. A
NetworkPolicy is evaluated after kube-proxy has translated the NodePort, so the
NodePort number would match nothing here while looking strict - and being the
only rule for the port, it would block every player instead.
*/}}
- ports:
- protocol: TCP
port: {{ .Values.server.port | int64 }}
{{- end }}
{{- if not (eq .Values.server.service.type "NodePort") }}
{{- /*
THE GAME PORT, CLUSTER-INTERNAL BRANCH (the default): the Service is not a
NodePort, so nothing outside the cluster can reach this port through this
chart, and the rule is restricted to this namespace plus allowedNamespaces.
PUT THE ROUTER'S NAMESPACE IN allowedNamespaces. In this cluster players reach
the server through minecraft-router, which runs in its own namespace - so
without that entry the server is unreachable and the symptom is a connection
timeout in a game client, with a healthy pod behind it.
*/}}
- ports:
- protocol: TCP
port: {{ .Values.server.port | int64 }}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.server.rcon.enabled }}
{{- /*
RCON - remote console, i.e. full administrative control of the server. No
Service and no probe, so it is restricted to this namespace plus
allowedNamespaces: the rule with the most security value in this chart, and it
costs nothing.
GATED ON server.rcon.enabled, AND THAT GATE IS NOT COSMETIC. The containerPort
itself is rendered only when RCON is on (see the StatefulSet), so without the
same condition here the policy would name a port the pod does not declare -
which is not merely untidy: ci/run-networkpolicy-test.sh rejects it, and it
rejected exactly this while the chart was being written. A rule for a port
nothing listens on is a rule nobody can verify.
*/}}
- ports:
- protocol: TCP
port: {{ .Values.server.rcon.port | int64 }}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- end }}
{{- /*
The metrics port. No Service, no probe, so restricted like RCON. A Prometheus
that scrapes this release from another namespace needs that namespace in
allowedNamespaces - and the failure is a target that silently stops reporting,
which is worth knowing before it happens.
*/}}
- ports:
- protocol: TCP
port: 19565
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
Outbound HTTPS. Needed for Mojang's session server when online-mode is on - a
server that cannot reach it rejects every login - and for resource-pack and mod
downloads. To any destination, because those are hostnames.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-30
View File
@@ -9,36 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-53
View File
@@ -313,56 +313,3 @@ curseforgeServerpack:
# Longhorn.
downloadUrl: null
workingDirectory: '/data/serverpack'
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# THIS IS ONE OF THE TIGHTEST POLICIES IN THE REPOSITORY, and the reason is
# the probes: all three are EXEC probes, which run inside the container and
# put no packet on the network. So there is no kubelet traffic to
# accommodate, every port can be restricted by source, and no probeSources
# value is needed at all.
#
# WHAT IT ALLOWS, inbound:
# - the game port (server.port): OPEN when server.service.type is NodePort
# (players arrive from the internet, already masqueraded), and
# RESTRICTED to this namespace plus allowedNamespaces otherwise - which
# is the default. Both branches read the same value the Service does.
# - RCON (server.rcon.port) and metrics (19565): always restricted. RCON is
# full administrative control of the server, so this is the rule with the
# most security value here, and it costs nothing.
# Outbound: DNS and TCP 443 (Mojang's session server for online-mode, plus
# resource packs and mods).
#
# PUT THE ROUTER'S NAMESPACE IN allowedNamespaces when the Service is not a
# NodePort. In this cluster players reach the server through
# minecraft-router, in its own namespace; without that entry the server is
# unreachable and the symptom is a timeout in a game client with a healthy
# pod behind it. The same applies to a Prometheus scraping 19565.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Add a port for a mod pack that calls out somewhere other than 443 - an
# authentication proxy, a map renderer upload, a Discord bridge.
extraPorts: []
+2 -2
View File
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
type: application
version: 8.1.0+up1.11.0
appVersion: 1.11.0
version: 7.2.0+up1.10.1
appVersion: 1.10.1
maintainers:
- name: jklein
-54
View File
@@ -235,57 +235,3 @@ FORCE_HTTPS IS ON
header. Turned on, Outline redirects to https itself — which loops
endlessly if that header does not reach the pod.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 3000 only. Outbound: DNS, TCP 5432 (Postgres), 6379 (Redis),
443 (OIDC discovery){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}.
THOSE EGRESS PORTS ARE THE STANDARD ONES, NOT YOUR CONFIGURED ONES. The
database, Redis and OIDC URLs all live inside 1Password items that this chart
never parses, so it cannot know where your dependencies listen. A dependency
on another port is blocked — and since all three probes hit /_health, which
checks Postgres and Redis, the symptom is a pod that never becomes Ready
rather than a connection error anyone sees. Add the real port to
networkPolicy.egress.extraPorts.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
@@ -1,192 +0,0 @@
{{- /*
NetworkPolicy for outline (Gitea #5).
Outline is the first chart of this rollout whose policy has real work to do in
BOTH directions, and it is also the one that proves the egress cap bites: all
three of its probes hit /_health, which checks Postgres AND Redis, so a pod
that cannot reach its data stores never becomes Ready.
THE EGRESS PORTS ARE THE CHART'S ONLY KNOWLEDGE OF ITS DEPENDENCIES, AND THEY
ARE A GUESS AT THE STANDARD ONES. Outline's database, Redis and OIDC provider
are configured through connection URLs inside 1Password items - the chart never
sees a host or a port, cannot derive one, and cannot validate one. So the rules
below name the standard ports (5432, 6379, 443) and nothing else. A deployment
whose Postgres listens anywhere else is BLOCKED, and it finds out through an
application-level connection error, not through this object. That is the
upgrade risk of this chart's policy, stated plainly; the escape hatch is
networkPolicy.egress.extraPorts.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is
evaluated at the pod's network interface, after kube-proxy has already
translated a NodePort to the targetPort, so a NodePort number here would match
nothing while looking strict - and, being the only rule for that port, would
block every inbound connection instead. ci/run-networkpolicy-test.sh asserts
every port here against the containerPorts of the pod spec this policy
selects.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if
this matches no pod spec of the same render - which is the mistake that
would otherwise leave a policy in the namespace filtering nothing, and
its usual cause is a label renamed in the workload template.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed because there IS something to restrict: this workload
needs DNS and the ports named below, and nothing else. A chart whose
workload opens no outbound connection of its own must NOT list Egress -
valkey is the example - because a rule that constrains nothing makes the
object look stricter than it is.
*/}}
- Egress
ingress:
{{- /*
3000 - the only port this pod declares, the one the Service targets, and the
one all three probes address (/_health). It is therefore the probe path.
*/}}
- ports:
- protocol: TCP
port: 3000
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress goes dark the
moment this list is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
Postgres. The port, not the host - outline.secrets.dbSecretRef carries a
connection URL the chart never parses, so this is the STANDARD port and not
the configured one. A Postgres on any other port is blocked and must be added
to networkPolicy.egress.extraPorts.
*/}}
- ports:
- protocol: TCP
port: 5432
{{- /*
Redis/Valkey, same reasoning as Postgres above: the standard port, because
outline.secrets.redisSecretRef holds a URL this chart does not read.
*/}}
- ports:
- protocol: TCP
port: 6379
{{- /*
Outbound HTTPS. Needed for the OIDC provider: Outline fetches the issuer's
discovery document at boot, and the discovery URL also lives inside a secret.
443 to any destination is as narrow as this object gets for that - a hostname
cannot appear in a NetworkPolicy. An OIDC provider on a non-standard port (a
CI dex on 5556, for instance) needs extraPorts.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
-51
View File
@@ -9,57 +9,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
-87
View File
@@ -171,90 +171,3 @@ httpRoute:
# link, every share URL and the OIDC redirect URI still name the old host
# until ingress.domain is changed too.
hostnames: []
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 3000 only - the single port this pod
# declares - and outbound DNS plus TCP 5432 (Postgres), 6379 (Redis) and 443
# (the OIDC provider's discovery document, fetched at boot).
#
# WHAT IT DOES NOT DO, and this is the one to read before upgrading: THOSE
# THREE EGRESS PORTS ARE THE STANDARD ONES, NOT THE CONFIGURED ONES. Outline's
# database, Redis and OIDC provider all arrive as connection URLs inside
# 1Password items, which this chart never parses - so it cannot know your
# ports and cannot validate them. A dependency on a non-standard port is
# BLOCKED, and because all three probes hit /_health (which checks Postgres
# and Redis), that shows up as a pod which never becomes Ready. Put the real
# port in egress.extraPorts below.
#
# The 3000 rule is NOT restricted by source in the default - 3000 is the
# probe port, see probeSources. And a policy is only a boundary where the CNI
# enforces one; unenforced, it is accepted and filters nothing.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 3000, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# Outline needs one entry here more often than most charts: a Postgres or
# Redis on a non-standard port, an OIDC provider that is not on 443 (a CI
# dex on 5556), an S3 endpoint on a custom port, or SMTP - the chart sets
# no SMTP_* variable itself, but a deployment that adds them through
# outline.env needs 587 or 465 here too.
extraPorts: []
+2 -2
View File
@@ -5,9 +5,9 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
type: application
version: 14.1.0+up3.3.1
version: 12.3.0+up3.2.1
appVersion: 3.3.1
appVersion: 3.2.1
maintainers:
- name: jklein
+17 -151
View File
@@ -8,9 +8,6 @@
{{- $valkeySvc := printf "%s-valkey-service" .Release.Name -}}
{{- $pre := default (list) $scripts.preConsumptionScripts -}}
{{- $post := default (list) $scripts.postConsumptionScripts -}}
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
{{- $cronPinned := $cron.pinToServerNode -}}
{{- $cronDeadline := include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
@@ -187,14 +184,15 @@ WHAT THE 1PASSWORD ITEMS MUST CONTAIN
here, so a wrong field name surfaces as a mount failure on the CronJob's
pod — a describe on that pod, never a missing variable.
{{- end }}
{{- /*
The warning that used to stand here - "secrets.oidcSecretRef IS SET AND IS READ
BY NOTHING" - is gone with the value itself (13.0.0, Gitea #11). It cannot be
printed any more and would not be reached: the key is out of values.schema.json
too, so a release that still sets it fails BEFORE any template renders, with
"additional properties 'oidcSecretRef' not allowed". The fix is to delete the
key; values.yaml says why it is not coming back.
*/}}
{{- if .Values.secrets.oidcSecretRef }}
secrets.oidcSecretRef IS SET AND IS READ BY NOTHING
"{{ .Values.secrets.oidcSecretRef }}" — no template in this chart references
that value. No OnePasswordItem is created for it and no Secret appears. The
OIDC client id travels as plain env from oidc.provider.clientId, and no
client secret is configured at all (see the OIDC section below). Whatever
you put in that item has no effect on this release.
{{- end }}
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
Helm never sees the content of a OnePasswordItem, so it cannot hash it, and
@@ -342,44 +340,12 @@ HOW DOCUMENTS GET IN
move, not a copy: once the job has run, the share no longer holds the file
and the only copy is on the consume volume.
The schedule is quoted on the way into the manifest since 13.0.0, so any
cron notation works — one starting with "*" or an "@daily" macro included.
Before that such a schedule aborted the render with a YAML parse error
pointing at the template, and the advice here was to avoid it; if you are
carrying a workaround schedule from that era ("5,20,35,50 * * * *" instead
of every 15 minutes), you can write what you meant now.
{{- if $cronPinned }}
PLACEMENT: the mover runs on the NODE OF THE STATEFULSET POD, via a
required podAffinity on its label (Gitea #101). Without that it lands
anywhere, and because it mounts the consume volume — RWX, from Longhorn —
the kubelet of the StatefulSet's node then retries the mount every two
minutes with "no Pending workload pods for volume ... to be mounted". It
was measured at 291 such events in 10 hours.
THE PRICE, and it is the one thing to know before scaling down: with no
StatefulSet pod the mover CANNOT BE SCHEDULED. scaleDown: true suspends
this CronJob outright, so that case is covered — but paperless.replicas: 0
with SMB still on is not, and there the job fails once per schedule after
activeDeadlineSeconds ({{ $cronDeadline }}s) with reason DeadlineExceeded. That is
deliberate: a bounded, visible failure rather than a Pending pod per
schedule that nobody sees. Switch smbConnection.cron.pinToServerNode to
false to go back to unpinned placement — and to the mount errors.
What the pinning does NOT fix: the consume volume stays RWX with a
share-manager behind it. Co-locating the two consumers removes the
cross-node mount, not the second consumer.
{{- else }}
PLACEMENT IS OFF (smbConnection.cron.pinToServerNode: false). The mover
will be scheduled wherever there is room, and when that is not the node of
the StatefulSet pod, Longhorn refuses to mount the shared RWX consume
volume there: the kubelet of the StatefulSet's node retries every two
minutes with "no Pending workload pods for volume ... to be mounted"
(Gitea #101, measured at 291 events in 10 hours). The errors stop when the
StatefulSet pod is deleted and comes back Pending, and return with the next
firing of this job.
{{- end }}
One thing to know before you edit that schedule: it is rendered into the
manifest unquoted, so a schedule that STARTS with "*" ("*/15 * * * *")
aborts the render with a YAML parse error on this template — YAML reads the
leading * as an alias. Quoting it in your values file does not help, the
quotes are gone by then. Use an equivalent that starts with a digit
("5,20,35,50 * * * *"); a "*" anywhere but in first position is fine.
The job has NO probes (the documented exception to the chart conventions),
and it cannot fail usefully either: with nothing to move it exits 0, which
@@ -466,13 +432,8 @@ OIDC IS ON, AND IT IS PKCE-ONLY
{{- if not .Values.oidc.provider.tokenAuthMethod }}
oidc.provider.tokenAuthMethod is unset. Since the allauth update in 3.0 some
providers need it spelled out, and the symptom when they do is a login that
dies at the callback with invalid_client. The only value this chart accepts is
"none" — it states the public client explicitly. A method that authenticates
WITH a client secret cannot work here and is rejected by the schema (13.0.0,
Gitea #11): this release configures no client secret and cannot, because
paperless reads the whole OIDC configuration out of one json environment
variable with no _FILE variant.
providers need it spelled out (client_secret_basic); the symptom is a login
that dies at the callback with invalid_client.
{{- end }}
{{- if or .Values.oidc.autoLoginWithProvider .Values.oidc.disableRegularLogin }}
@@ -492,20 +453,6 @@ paperless.scripts IS SET AND DOES NOTHING
PAPERLESS_PRE_CONSUME_SCRIPT nor PAPERLESS_POST_CONSUME_SCRIPT is set on the
container. These values render no object and change no behaviour.
{{- end }}
{{- if .Values.paperless.env }}
paperless.env ADDS {{ len .Values.paperless.env }} VARIABLE(S) — AND AN OVERRIDE ONLY BITES AT INSTALL
{{ range $i, $e := .Values.paperless.env }}{{ if $i }}, {{ end }}{{ $e.name }}{{ end }}
These are appended AFTER the variables the chart sets itself, so at INSTALL an
entry repeating one of those names wins. On an upgrade of an EXISTING release
the same entry is lost: Kubernetes merges env by name, the pair collapses to
the chart's own value, and helm upgrade still reports success. Read the value
back off the StatefulSet, never off the upgrade's exit code:
kubectl -n {{ .Release.Namespace }} get statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs \
-o jsonpath='{.spec.template.spec.containers[0].env}'
{{- end }}
{{- if gt (int $replicas) 1 }}
paperless.replicas IS {{ $replicas }} — EXPECT ONE POD TO WORK AND THE REST TO WAIT
@@ -531,84 +478,3 @@ BACKUP IS TWO THINGS AT THE SAME TIME
paperless.env adds {{ len .Values.paperless.env }} variable(s), rendered after the ones the chart
sets itself, so they win on a name the chart also sets.
{{- end }}
{{- if .Values.networkPolicy.enabled }}
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
Inbound: TCP 8000 only. Outbound: DNS, TCP 5432 (Postgres), 6379
(Redis/Valkey), 443{{ if .Values.paperless.files.tikaGotenberg.enabled }}, 9998 and 3000 (the Tika and Gotenberg POD ports) plus 80{{ end }}{{ with .Values.networkPolicy.egress }}{{ if .mailImport }}, 993 (IMAPS, mail import){{ end }}{{ with .extraPorts }}, plus TCP
{{ join ", " . }}{{ end }}{{ end }}.
{{- if .Values.paperless.files.tikaGotenberg.enabled }}
THE TIKA/GOTENBERG RULE NAMES POD PORTS, AND THAT IS THE FIX FROM GITEA #136.
It named the Services' 80 alone until chart 14.1.0, which cut Paperless off
from both services: 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 symptom was a TIMEOUT and not "connection refused",
and no error mentioned a NetworkPolicy. If this release reaches a Tika or
Gotenberg that listens on some other port, that port goes into
networkPolicy.egress.extraPorts.
{{- end }}
{{- if (.Values.networkPolicy.egress).mailImport }}
MAIL IMPORT IS PERMITTED on 993 (IMAPS), because
networkPolicy.egress.mailImport is on. Plain IMAP (143) and POP3 are NOT
covered — add them to networkPolicy.egress.extraPorts if an account uses them.
{{- else }}
MAIL FETCHING IS NOT PERMITTED. 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 networkPolicy.egress.mailImport: true for IMAPS (993),
or 143/POP3 in networkPolicy.egress.extraPorts — otherwise a mail rule
silently stops importing, with a healthy pod behind it.
{{- end }}
AND TWO PODS OF THIS RELEASE ARE NOT COVERED BY THIS OBJECT:
- the valkey subchart's pod, which carries its own labels. It needs no
cover from here: the valkey chart ships its own NetworkPolicy, and a
tighter one.
- the consume CronJob's pods, whose pod template carries NO labels at all,
so no podSelector can match them. They move files between mounted
volumes and reach the SMB share through a CSI volume the NODE mounts, so
the pod opens no connection of its own — but the gap is real and is named
here rather than left to be discovered.
Three limits belong in the same breath, because none of them is visible in
the object itself:
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
selectors and cannot express a hostname, so every egress rule permits its
port to ANY address. That is a limit of the object, not a shortcut — this
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
outbound port the chart does not know about is BLOCKED: add it to
networkPolicy.egress.extraPorts, and expect to find out through an
application-level connection error rather than through this object.
{{- if .Values.networkPolicy.probeSources }}
- THE PROBE PATH IS RESTRICTED in this release, because
networkPolicy.probeSources is set to
{{ join ", " .Values.networkPolicy.probeSources }}
Probes come from the IP of the WORKER NODE running the pod. If none of those
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
looks like a broken application. Compare
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
against `kubectl get node -o wide`.
{{- else }}
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
empty, so that rule carries no source selector at all and any pod the CNI
routes to can reach it. Not from the internet — but this is NOT pod-level
isolation. Narrowing it means listing the worker node IPs in
networkPolicy.probeSources, because the kubelet probes from the node's own
address and the chart cannot know it; a wrong guess there leaves the pod
permanently un-Ready.
{{- end }}
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
this object is accepted and filters nothing, so everything reads as
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
shows the object exists; only a connection attempt from an unrelated
namespace shows that it bites.
Switch it off with networkPolicy.enabled: false.
{{- end }}
-38
View File
@@ -340,41 +340,3 @@ The same three fallbacks for the SMB consume-mover CronJob. It has no probes
{{- $merged := fromYaml (include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron.resources")) -}}
{{- include "paperless-ngx.resources" $merged -}}
{{- end -}}
{{/*
The whole smbConnection.cron block, merged onto its defaults (YAML; consume
with fromYaml).
Input: .Values.smbConnection.cron (may be nil, empty or partial).
WHY EVERY READ OF THAT BLOCK GOES THROUGH HERE. The CronJob template used to
dereference .Values.smbConnection.cron.<key> directly, so an override that had
erased the block - the leftover childless
cron:
that issue #61 is about - did not fall back to the defaults the way this chart
promises everywhere else. It aborted the render with
nil pointer evaluating interface {}.securityContext
naming an internal template path and neither the value nor the cause. Measured
on origin/main before 13.0.0, so this is a fix and not a precaution.
The scalar defaults below MUST stay in sync with the smbConnection.cron block
in values.yaml, which stays the documented, user-facing place for them; this
copy is only the fallback. The sub-blocks (securityContext, resources, env)
keep their own defaults in the helpers above and are only passed through here.
An explicit "pinToServerNode: false" survives this, which is the whole reason
it is not a "default true" pipe: sprig's default would read false as unset and
silently turn the pinning back on - the one value whose explicit false has to
be obeyed, since it exists to switch off a hard placement rule.
*/}}
{{- define "paperless-ngx.cron.scheduling" -}}
{{- $defaults := dict
"pinToServerNode" true
"startingDeadlineSeconds" 600
"activeDeadlineSeconds" 3600 -}}
{{- include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron") -}}
{{- end -}}
@@ -1,165 +1,26 @@
{{- if .Values.smbConnection.enabled }}
{{- /*
The cron block, merged onto its defaults, so that an override which erased it
falls back instead of aborting with a nil-pointer error naming an internal
template path - which is what every direct .Values.smbConnection.cron.<key>
read did before 13.0.0. The helper carries the measurement.
*/}}
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-consume-cronjob
spec:
{{- /*
QUOTED, AND THAT IS THE WHOLE OF GITEA #11's FIRST HALF. The default
"0 0,12 * * *" renders safely only because it begins with a digit. The most
common cron notation of all - a star, a slash and an interval, every 15
minutes - begins with "*", which YAML reads as an alias indicator, and the
render ABORTS:
(and the notation cannot be written out in this comment, because the two
characters that start it would end a Go template comment right here)
Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
error converting YAML to JSON: yaml: line 6: did not find expected
alphabetic or numeric character
The trap is that quoting it in the VALUES does not help: Helm passes the
string's content through, and the star stands bare in the manifest again. So
the operator writes a correctly quoted value and gets an error that points at
this template instead of at their value. "@daily" fails the same way ("@" is
a reserved YAML indicator).
ci/run-yaml-scalar-render-test.sh is the test that would have caught it, and
ci/yaml-scalar/paperless-ngx.yaml is the overlay that feeds it a star-leading
schedule - needed because this whole template sits behind
smbConnection.enabled, which is false in every other fixture, so nothing else
in the repository renders this object at all.
*/}}
schedule: {{ .Values.smbConnection.cronJob | quote }}
{{- /*
Forbid, hard-wired rather than a value: two movers running at once race on
the same files ("mv /mnt/smb/*.pdf" twice over one directory), and the loser
reports a file that is already gone. There is no configuration in which
overlapping runs of this job are wanted, so there is nothing to configure.
*/}}
concurrencyPolicy: Forbid
{{- /*
Bounds how late a missed run may still start, and it is here for a second
reason that has nothing to do with lateness: while the CronJob is suspended
(scaleDown) or its runs are being suppressed, the controller counts missed
schedules, and above 100 it STOPS SCHEDULING ALTOGETHER with only
"Cannot determine if job needs to be started: too many missed start times" in
the controller log. With this field set, the controller only counts misses
inside this window, so that state cannot be reached. The cost is stated in
values.yaml: a run that could not start inside the window is skipped, not
queued.
*/}}
startingDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.startingDeadlineSeconds }}
schedule: {{ .Values.smbConnection.cronJob }}
{{- if .Values.scaleDown }}
suspend: true
{{- end }}
jobTemplate:
spec:
{{- /*
THE SAFETY NET UNDER THE AFFINITY BELOW (Gitea #101). activeDeadlineSeconds
counts from the job's start, not from the pod's, so it also bounds a pod
that NEVER gets scheduled: when the StatefulSet pod is absent and the
required affinity cannot be satisfied, the job fails after this many
seconds with reason DeadlineExceeded instead of sitting Pending forever
and gaining a sibling every schedule. That turns the one new failure mode
this issue's fix could introduce into a visible, self-clearing one -
failedJobsHistoryLimit (Kubernetes' default, 1) keeps the evidence.
*/}}
activeDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds }}
template:
spec:
{{- if $cron.pinToServerNode }}
{{- /*
GITEA #101: this pod mounts the consume volume, which is RWX and comes
from Longhorn. Without a placement rule the scheduler puts it on any
node, and Longhorn's CSI driver then refuses the mount on the node
where the StatefulSet pod runs:
MountVolume.SetUp failed ... code = Aborted desc = no Pending
workload pods for volume pvc-... to be mounted:
map[Running:[...-sfs-0] Succeeded:[...-consume-cronjob-...]]
Measured at every two minutes, 291 events in 10 hours, in both
releases that have SMB switched on; the release with
smbConnection.enabled: false and therefore no CronJob produced none of
them on identically built volumes.
WHY podAffinity AND NOT nodeSelector: the StatefulSet pod is not
pinned either - it sits wherever the scheduler put it and moves on
every restart. A static nodeSelector would be correct exactly until
then and silently wrong afterwards. The affinity follows the pod.
WHY required AND NOT preferred: preferred falls back to another node
in the very situation this exists for, which reproduces the bug more
rarely and therefore harder to find. The fallback is what went
unnoticed for ten hours.
The label is the one the StatefulSet puts on its pod template
(paperless-server.sfs.yaml, metadata.labels.app) and is read off the
running pod, not assumed.
WHAT THIS DOES NOT FIX: the volume stays RWX and keeps its
share-manager; co-locating the two consumers removes the CROSS-NODE
attach, not the second consumer. The design that removes it is moving
the mv into the StatefulSet pod, which cannot be done for the running
installations because RWX cannot be taken back off an existing PVC.
Gitea #101 names it as the alternative and not as the proposal.
*/}}
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
topologyKey: kubernetes.io/hostname
{{- end }}
automountServiceAccountToken: false
securityContext:
{{- include "paperless-ngx.cron.podSecurityContext" $cron.securityContext | nindent 12 }}
{{- include "paperless-ngx.cron.podSecurityContext" .Values.smbConnection.cron.securityContext | nindent 12 }}
containers:
- name: paperless-consume-cronjob
image: "{{ .Values.paperless.busybox.image.repository }}:{{ .Values.paperless.busybox.image.tag }}"
imagePullPolicy: IfNotPresent
securityContext:
{{- include "paperless-ngx.cron.containerSecurityContext" $cron.securityContext | nindent 16 }}
{{- if $cron.env }}
env:
{{- range $value := $cron.env }}
{{- if $value.value }}
{{- /*
quote is required, not cosmetic: a value like "8080" or "true"
renders unquoted as a YAML number/bool, and the API server then
rejects the whole manifest with "cannot unmarshal number into
Go struct field EnvVar.value of type string". A template
comment and not a "#" one, because a "#" line here is part of
the rendered manifest.
*/}}
- name: {{ $value.name | quote }}
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- include "paperless-ngx.cron.containerSecurityContext" .Values.smbConnection.cron.securityContext | nindent 16 }}
command:
- /bin/sh
- -c
@@ -172,7 +33,7 @@ spec:
mountPath: "/mnt/consume"
readOnly: false
resources:
{{- include "paperless-ngx.cron.effectiveResources" $cron.resources | nindent 16 }}
{{- include "paperless-ngx.cron.effectiveResources" .Values.smbConnection.cron.resources | nindent 16 }}
restartPolicy: Never
volumes:
- name: smb-share
@@ -181,4 +42,4 @@ spec:
- name: consume-folder
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-consume-volume-claim
{{- end }}
{{- end }}
@@ -1,293 +0,0 @@
{{- /*
NetworkPolicy for paperless-ngx (Gitea #5).
Paperless reaches a Postgres and a Redis, and - when
paperless.files.tikaGotenberg.enabled is true - the Tika and Gotenberg services,
which in this repository are two other charts whose Services listen on port 80.
That last rule is derived from the switch rather than always rendered, so a
release without Tika does not carry a rule that permits nothing.
THREE THINGS THIS POLICY DOES NOT COVER, and all three are real rather than
theoretical (values.yaml lists the same three; this said "TWO" while listing
three, which is corrected here because the third one is the mail rule below):
1. THE VALKEY SUBCHART'S POD. This chart declares valkey as an optional
dependency, and the subchart renders its own StatefulSet with its own
labels. It is not covered by THIS policy - but it does not need to be: the
valkey chart ships a NetworkPolicy of its own, and it is the tighter one
(Ingress-only, 6379 restricted by source, because its probes are exec probes
and no kubelet traffic has to be let through). This is the one subchart in
the repository that brings its own policy.
2. THE CONSUME CRONJOB'S PODS. paperless-consume.cronjob.yaml renders a pod
template with NO LABELS AT ALL, so no podSelector can match it and no policy
in this chart can restrict it. That is a gap in the convention, named here
rather than glossed. What makes it tolerable: the CronJob's whole job is to
move files between two mounted volumes, and the SMB share reaches it as a
CSI volume - mounted by the NODE, not opened by the pod - so the pod itself
opens no connection that a policy would have anything to say about. If that
ever changes, the CronJob needs labels before it can need a policy.
3. MAIL FETCHING IS NOT PERMITTED UNLESS THE OPERATOR SAYS SO. Paperless' mail
accounts live in its DATABASE, configured through the web UI, so this chart
cannot see them and cannot derive the rule. networkPolicy.egress.mailImport
opens IMAPS (993) and is off by default; plain IMAP (143) and POP3 belong in
networkPolicy.egress.extraPorts. Left off on a release that does fetch mail,
the symptom is a mail rule that silently stops importing.
WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer.
Only the ports declared below are reachable at all - whatever else the process
binds, now or after the next app update, is blocked without anyone having to
notice it exists.
WHAT IT IS NOT:
- EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER
hostnames, so "only to the database host" cannot be expressed 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 this chart
to Calico, the same argument that kept implementation-specific objects out
of the Gateway-API sweep (#114). Each egress rule therefore permits its
PORT to any destination.
- NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is
accepted by the API server and filters nothing, so everything reads as
configured while nothing is filtered.
- THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part
of the rendered object, travel into the cluster and appear in every release
diff. ci/run-networkpolicy-test.sh fails on a rendered "#".
THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS AND NEVER SERVICE PORTS.
A NetworkPolicy is evaluated at the pod's network interface, after kube-proxy
has already done its destination NAT - of a NodePort on the way in, and of a
ClusterIP on the way OUT. So a NodePort number in an ingress rule matches
nothing while looking strict (and, being the only rule for that port, blocks
every inbound connection instead), and a SERVICE port in an egress rule matches
nothing wherever the Service's port and its targetPort differ. The second half
is not hypothetical: it is Gitea #136, it cost an outage on 2026-10-15, and the
sentence that caused it is quoted as refuted at the Tika/Gotenberg rule below.
ci/run-networkpolicy-test.sh asserts every INGRESS port here against the
containerPorts of the pod spec this policy selects. Egress cannot be checked
that way - an egress port belongs to the DESTINATION, which for Postgres, Redis
and the web is not even in the render. The one exception is Tika and Gotenberg,
which are two charts of THIS repository: the same test renders them and asserts
that the egress rule below contains their containerPorts.
*/ -}}
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
{{- $ns := .Release.Namespace }}
{{- $probeSources := $np.probeSources | default list }}
{{- $allowedNs := $np.allowedNamespaces | default list }}
{{- $ic := $np.ingressController | default dict }}
{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy
spec:
{{- /*
Selects the paperless server's StatefulSet pods. Note that this render may
contain the valkey subchart's pods as well - this selector must not match them,
and ci/run-networkpolicy-test.sh reports which pod templates it does match so
that is visible rather than assumed.
*/}}
podSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
policyTypes:
- Ingress
{{- /*
Egress is listed because there is a lot to restrict and most of it is known.
What is NOT permitted by default and is the plausible surprise: mail. Paperless'
mail accounts are rows in its database, configured in the web UI, so the chart
cannot see them - networkPolicy.egress.mailImport opens IMAPS 993, and anything
else (143, POP3) goes into networkPolicy.egress.extraPorts.
*/}}
- Egress
ingress:
{{- /*
8000 - the only port this pod declares, the one the Service forwards 80 to, and
the port all three probes address. The probe path.
*/}}
- ports:
- protocol: TCP
port: 8000
{{- if $probeSources }}
{{- /*
RESTRICTED, because the operator supplied probeSources. The kubelet's probes
come from the IP of the WORKER NODE running the pod, so that address has to be
in the list or the pod never becomes Ready - and a pod that is never Ready
reads as a broken application rather than as a policy.
The ingress controller is added to the same rule, or the Ingress goes dark the
moment this list is filled.
*/}}
from:
- namespaceSelector:
matchLabels:
{{- /*
Set by Kubernetes itself on every namespace (>= 1.21), so this needs no
labelling work on a namespace this chart does not own.
*/}}
kubernetes.io/metadata.name: {{ $ns }}
{{- if and $routed $ic.namespace }}
{{- /*
ONE from-entry with TWO selectors, which is an AND: pods carrying these labels
IN that namespace. Written as two list entries it would be an OR, and would
open the port to every pod in kube-system.
*/}}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ $ic.namespace | quote }}
{{- with $ic.podLabels }}
podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- end }}
{{- range $allowedNs }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
{{- range $probeSources }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- else }}
{{- /*
NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the
cluster, because this port carries no NodePort. The alternative was to guess
the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod
stays un-Ready, the rollout times out, and the cause looks like the
application. An empty "from: []" would be worse than either - it allows
everything too, but while reading as a restriction. Narrow it with
networkPolicy.probeSources.
*/}}
{{- end }}
egress:
{{- /*
DNS, unconditionally. There is no value to switch this off, and that is
deliberate: a knob whose only other setting breaks every name lookup in
the pod is a trap and not a feature.
*/}}
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- /*
PostgreSQL on the STANDARD port. The connection details live in a 1Password
item this chart never parses, so this is the conventional port and not the
configured one; a Postgres elsewhere is blocked and belongs in
networkPolicy.egress.extraPorts.
*/}}
- ports:
- protocol: TCP
port: 5432
{{- /*
Redis/Valkey on the STANDARD port, same reasoning. This covers the bundled
valkey subchart as well as an external one - the subchart's Service uses 6379
too.
*/}}
- ports:
- protocol: TCP
port: 6379
{{- if .Values.paperless.files.tikaGotenberg.enabled }}
{{- /*
TIKA AND GOTENBERG, ON THEIR POD PORTS 9998 AND 3000 - and 80 as well, for a
third case named at the end. Gitea #136: this rule used to name 80 ALONE, and
the release that shipped it cut Paperless off from both services on
2026-10-15. Mail import, EML, HTML and Office documents stopped; plain PDFs
and images kept working, which is why it did not look like a network fault.
THE SENTENCE THAT STOOD HERE IS WRONG. It is quoted rather than deleted,
because it reads perfectly and that is precisely how it survived review:
"an EGRESS rule names the port on the DESTINATION as the client dials
it, and the client dials the Service."
kube-proxy translates the destination of a ClusterIP Service (DNAT) 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. A Service port only ever matches by coincidence, where
port and targetPort are the same number - which is the case for 5432, 6379
and 443 above and below, and is the reason nothing else here broke.
THE SYMPTOM IS A TIMEOUT, NOT "connection refused", and that is the half to
remember: a missing service REFUSES, a dropped packet RUNS INTO THE TIMEOUT.
What this produced was httpx.ConnectTimeout out of paperless' parser, naming
the Tika URL and no NetworkPolicy at all - nothing in the error points here.
WHY 80 STAYS: it costs nothing and covers an installation whose Tika really
does listen on 80 - a foreign one behind a proxy, where 80 IS the pod port.
WHY THERE IS NO SWITCH OF ITS OWN for this rule, and that is deliberate: it
is gated on the FEATURE value, because a second value that can drift from the
feature re-arms exactly this outage. Whoever enables Tika and forgets the
policy switch lands back here, with the same message that does not point at
the policy. Rendered only while paperless.files.tikaGotenberg.enabled is
true, so a release without them still carries no rule that permits nothing.
9998 and 3000 are the containerPorts the apache-tika and gotenberg charts of
this repository declare, so they are fixed rather than guessed - and
ci/run-networkpolicy-test.sh renders both of those charts and asserts that
this rule contains them. That is the one assertion a single-chart render
cannot make, and it is why this mistake was not caught the first time.
*/}}
- ports:
- protocol: TCP
port: 9998
- protocol: TCP
port: 3000
- protocol: TCP
port: 80
{{- end }}
{{- /*
Outbound HTTPS, for the trained classifier's model downloads and for any
remote resource a consumed document pulls. To any destination, because those
are hostnames.
*/}}
- ports:
- protocol: TCP
port: 443
{{- with $np.egress }}
{{- if .mailImport }}
{{- /*
IMAPS for mail import, OFF BY DEFAULT - because this chart cannot know
whether a release fetches mail. Paperless' mail accounts are rows in its
DATABASE, entered in the web UI, and nothing in the values hints that they
exist. So this value is an operator's statement ("this release imports
mail") and not a derivation, which is also why it cannot be folded into
another switch.
993 ONLY. Plain IMAP (143) and POP3 (110, 995) are deliberately NOT included:
a value named mailImport that quietly opened four ports would be wider than
its name, and the plaintext variants are not what a new account uses. They go
into networkPolicy.egress.extraPorts.
AND THIS PORT IS THE REAL DESTINATION PORT, unlike the Tika rule above. A
mail server outside the cluster is reached with no Service and therefore no
DNAT in the path, so 993 is both what paperless dials and what the policy
sees. What decides which number an egress rule needs is the DNAT, not the
direction and not the protocol - that is the whole lesson of #136.
*/}}
- ports:
- protocol: TCP
port: 993
{{- end }}
{{- with .extraPorts }}
{{- /*
Ports only, to any destination - see the header. This is the operator's
escape hatch from the egress cap, and the only alternative to switching
the whole policy off.
*/}}
- ports:
{{- range . }}
- protocol: TCP
port: {{ int . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -204,41 +204,23 @@ spec:
- name: PAPERLESS_DISABLE_REGULAR_LOGIN
value: "{{ .Values.oidc.disableRegularLogin }}"
{{- end }}
{{- /*
QUOTED SINCE 13.0.0, and it is a fix rather than a tidy-up (Gitea
#112, same genus as #11). This block is the chart's additive env
interface, and until 13.0.0 it rendered its operand bare. Two
measured consequences, both of them the operator's value arriving
as something else:
- a value beginning with "*" aborted the render, with the error
pointing at THIS template rather than at the value;
- "true" and "0755" rendered as a YAML bool and an integer, and
the API server rejects those with "cannot unmarshal number
into Go struct field EnvVar.value of type string" - the chart
renders, the install fails.
The quoted form is the repository's documented one
(template-chart/templates/xxx.deployment.yaml carries the rule);
this chart was one of seven that had not been brought to it.
*/}}
{{- range $value := .Values.paperless.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
value: {{ tpl $value.value $ | quote }}
- name: {{ $value.name }}
value: {{ tpl $value.value $ }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
name: {{ tpl $value.valueFrom.secretKeyRef.name $ }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }}
{{- end }}
{{- end }}
{{- end }}
+2 -10
View File
@@ -23,16 +23,8 @@ metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
{{- /*
Quoted for the same reason as the env block in the StatefulSet and the
schedule in the CronJob (Gitea #11's genus): an annotation value must be a
string, maxUploadFileSize is free operator text, and a unit-less "100" -
schema-valid, since the schema types it as a string - renders bare as an
integer and the API server rejects the whole Ingress. The default "10m"
happens to be safe, which is exactly how this survived.
*/}}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
+6 -74
View File
@@ -51,61 +51,6 @@
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"networkPolicy": {
"description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowedNamespaces": {
"description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"probeSources": {
"description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.",
"type": ["array", "null"],
"items": {
"type": "string"
}
},
"ingressController": {
"description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"namespace": {
"type": ["string", "null"]
},
"podLabels": {
"description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.",
"type": ["object", "null"]
}
}
},
"egress": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"mailImport": {
"description": "Open outbound IMAPS (993) for paperless' mail import. Off by default, and a value rather than a derivation because paperless' mail accounts are rows in its DATABASE, entered in the web UI - nothing in these values says whether a release fetches mail. 993 only: plain IMAP (143) and POP3 (110, 995) are deliberately not covered and belong in extraPorts. Unlike the Tika/Gotenberg rule this is the real destination port, because a mail server outside the cluster is reached with no Service and therefore no destination NAT in the path (Gitea #136).",
"type": ["boolean", "null"]
},
"extraPorts": {
"description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk. For a destination INSIDE the cluster these are POD ports: kube-proxy translates a ClusterIP Service's port away before the egress policy is evaluated at the pod's interface, so a Service port matches nothing wherever port and targetPort differ (Gitea #136).",
"type": ["array", "null"],
"items": {
"type": "integer"
}
}
}
}
}
},
"scaleDown": {
"type": "boolean"
},
@@ -124,6 +69,9 @@
},
"smbConnectionSecretRef": {
"type": ["string", "null"]
},
"oidcSecretRef": {
"type": ["string", "null"]
}
}
},
@@ -331,28 +279,13 @@
"type": ["string", "null"]
},
"cronJob": {
"description": "Cron schedule of the consume-mover job. Deliberately without a pattern: cron syntax is richer than a regex here would admit, and the failure this value used to have was not a wrong schedule but a correct one rendered bare into the manifest - fixed at the render site with | quote (Gitea #11), not by narrowing what may be written.",
"description": "Cron schedule of the consume-mover job.",
"type": ["string", "null"]
},
"cron": {
"type": "object",
"additionalProperties": false,
"properties": {
"pinToServerNode": {
"description": "Place the consume-mover pod on the node that carries the StatefulSet pod, via a required podAffinity on its label (Gitea #101). Default true: the mover mounts the RWX consume volume, and Longhorn's CSI driver refuses that mount unless it finds a workload pod in Pending, so a mover on another node leaves the StatefulSet node's kubelet retrying every two minutes. false restores the unpinned behaviour and with it those mount errors.",
"type": ["boolean", "null"]
},
"startingDeadlineSeconds": {
"description": "How late a missed run may still start. Also bounds the CronJob controller's missed-schedule count, which above 100 stops this job being scheduled at all, reported only in the controller's own log.",
"type": ["integer", "null"]
},
"activeDeadlineSeconds": {
"description": "Hard stop for one mover run, counted from the job's start, so it also bounds a pod that never gets scheduled - with pinToServerNode on and no StatefulSet pod, the job fails with DeadlineExceeded instead of leaving a Pending pod per schedule.",
"type": ["integer", "null"]
},
"env": {
"$ref": "#/definitions/env"
},
"securityContext": {
"$ref": "#/definitions/securityContext"
},
@@ -393,9 +326,8 @@
"type": ["string", "null"]
},
"tokenAuthMethod": {
"description": "Explicit token endpoint auth method. Restricted to 'none' in 13.0.0 (Gitea #11): this chart registers a PUBLIC client with PKCE and configures no client secret, so a method that authenticates with one cannot work - it produced a login that died at the callback with invalid_client while reading like a working secret setup. null omits the key; 'none' states the public client explicitly, which some providers need spelled out since the allauth update in paperless-ngx 3.0.",
"type": ["string", "null"],
"enum": ["none", null]
"description": "Explicit token endpoint auth method (e.g. client_secret_basic). Some providers need this since the allauth update in paperless-ngx 3.0.",
"type": ["string", "null"]
}
}
}
+5 -252
View File
@@ -13,26 +13,7 @@ secrets:
databaseSecretRef: null
paperlessSecretRef: null
smbConnectionSecretRef: null
# REMOVED IN 13.0.0: secrets.oidcSecretRef (Gitea #11). It is gone from the
# schema too, so a release that still sets it now fails to render with
# additional properties 'oidcSecretRef' not allowed
# instead of being ignored - DELETE THE KEY from your values, nothing else is
# needed. It never did anything: no template read it, no OnePasswordItem was
# created for it, and whatever the item held never reached the pod.
#
# It is not coming back as a feature, and the reason is in paperless itself:
# the whole OIDC configuration travels in ONE json environment variable
# (SOCIALACCOUNT_PROVIDERS = json.loads(os.getenv(
# "PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"))), and there is no _FILE variant
# of it. A secretKeyRef cannot substitute a fragment INTO a json variable, so
# an honest implementation would have to either inline the secret in the pod
# spec or push the complete json into the vault by hand - and the second one
# makes every required() in the oidc block below dead wood.
#
# The operational question behind it is answered rather than dodged: this
# chart registers a PUBLIC client with PKCE, Keycloak (Gitea #83) can mark a
# client public per client, and so no provider constraint forces a
# confidential client here. See the oidc block below.
oidcSecretRef: null
ingress:
# Set to false to omit the Ingress entirely. NEW in the chart version that
@@ -198,38 +179,7 @@ paperless:
timeoutSeconds: 5
# Extra environment variables, appended to the ones the chart always sets.
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
# forms are supported. paperless-ngx reads around 80 PAPERLESS_* variables and
# this chart maps a handful of them explicitly - everything else goes here.
#
# env:
# - name: PAPERLESS_LOGGING_DIR
# value: /tmp/paperless-log
#
# ADDITIVE MEANS ADDITIVE, and the timing matters: an entry repeating a name
# the chart already sets reaches the pod twice, and at INSTALL the later one -
# yours - wins. On an EXISTING release the same entry is lost, because
# Kubernetes merges env by name and the pair collapses to the chart's own
# value, while helm upgrade still reports success. Read an override back off
# the StatefulSet, never off the upgrade's exit code.
#
# VALUES MUST BE STRINGS (the schema enforces it) and they are quoted on the
# way out since 13.0.0. Before that a value beginning with "*" aborted the
# render and "true" or "0755" arrived as a bool or an integer, which the API
# server rejects - see the comment at the render site in
# templates/paperless-server.sfs.yaml.
#
# THE WORKED EXAMPLE, PAPERLESS_LOGGING_DIR, AND WHAT IT DOES NOT FIX
# (jk-cluster-config #360): with the data directory on an RWX volume that
# Longhorn exports over NFS, paperless' concurrent_log_handler cannot hold its
# lock file there and fails with "RuntimeError: Cannot acquire lock after 20
# attempts" - measured at 45 % of the application log across all three
# installations. Pointing PAPERLESS_LOGGING_DIR at a path under the /tmp
# emptyDir this pod already mounts takes the log off the NFS share and the
# noise stops. It does NOT fix the RWX data volume, which is the actual root
# cause and a cluster-config decision (the PVC asks for ReadWriteOnce and was
# provisioned ReadWriteMany; taking RWX back means recreating the volume).
# A log under /tmp does not survive a pod restart - no loss here, paperless
# also writes to stdout, which is what kubectl logs reads.
# forms are supported.
env: []
resources:
# Limits are optional: memory and ephemeral-storage limits default to
@@ -264,58 +214,8 @@ paperless:
smbConnection:
enabled: false
server: null
# Cron schedule of the consume mover. Quote it or not as you like - the chart
# quotes it on the way into the manifest (since 13.0.0, Gitea #11), which is
# what a schedule beginning with "*" or "@" needs. Before that such a
# schedule aborted the render with a YAML error pointing at the template.
cronJob: "0 0,12 * * *"
cron:
# Run the mover on the node that carries the StatefulSet pod (Gitea #101).
#
# WHY IT DEFAULTS TO TRUE: this pod mounts the consume volume, which is RWX
# and comes from Longhorn, and Longhorn's CSI driver refuses to mount an RWX
# volume unless it finds a workload pod in Pending - a mover pod that ran on
# a different node leaves the kubelet of the StatefulSet's node retrying
# every two minutes with "no Pending workload pods for volume ... to be
# mounted" (291 events in 10 hours, in both releases with SMB on; the
# release without the CronJob produced none).
#
# It is a switch and not a free-form affinity block on purpose: the default
# has to name the StatefulSet pod's label, that label contains the RELEASE
# NAME, and a values.yaml is never templated - so the useful default cannot
# be expressed here at all (the same property that forces
# valkey.secrets.authSecretRef to be typed out twice, further down).
#
# WHAT IT COSTS, AND IT IS A REAL COST: the affinity is required, so with
# the StatefulSet pod absent the mover cannot be scheduled. The two ways
# that happens are handled rather than left open - scaleDown: true suspends
# this CronJob outright, and activeDeadlineSeconds below fails a job that
# cannot be placed instead of letting Pending pods pile up. What stays: with
# paperless.replicas: 0 and SMB on, the mover does not run and reports one
# failed job per schedule. Set this to false to get the old, unpinned
# behaviour back - including the mount errors.
pinToServerNode: true
# How late a missed run may still start. Also keeps the CronJob controller
# from reaching "too many missed start times (> 100)", after which it stops
# scheduling this job entirely and says so only in its own log - reachable
# by leaving scaleDown: true in place for months. The cost of a window: a
# run that could not start inside it is skipped, not queued. Must stay well
# above 10s, below which the controller may never schedule at all.
startingDeadlineSeconds: 600
# Hard stop for one mover run, counted from the job's start - so it also
# bounds a pod that never gets scheduled (see pinToServerNode). Exceeding it
# shows up as a job with reason DeadlineExceeded, which is the point: a
# loud, self-clearing failure instead of a Pending pod that nobody sees.
# Raise it if a run legitimately moves more than an hour's worth of files;
# the mover copies across filesystems, so large scans are not instant.
activeDeadlineSeconds: 3600
# Extra environment variables for the mover container, same form as
# paperless.env above. Deliberately empty: the container is a busybox
# running one mv, so this exists for the odd proxy or locale variable and
# for the convention that every workload container offers the block - not
# because the mover needs configuration. Note that the cron SCHEDULE's time
# zone is the controller's and cannot be changed with a TZ entry here.
env: []
# The consume mover only shuffles files between the SMB share (mounted
# with 0777 modes) and the consume PVC, so it runs as the same non-root
# uid/gid as paperless itself with a fully hardened context.
@@ -351,25 +251,9 @@ oidc:
name: null
clientId: null
discoveryUrl: null
# Optional: explicit token endpoint auth method. "none" is the only accepted
# value, and the schema enforces that (Gitea #11).
#
# WHY IT IS RESTRICTED, since the previous comment here named
# client_secret_basic as the example and that was actively misleading: this
# chart registers a PUBLIC client and configures NO client secret - it
# renders OAUTH_PKCE_ENABLED and nothing else, and secrets.oidcSecretRef was
# removed in 13.0.0 because no secret can reach paperless through a json
# environment variable anyway. A method that authenticates WITH a client
# secret therefore cannot work here; asking for one produced a login that
# died at the callback with invalid_client, while the two values read
# together looked like a working secret setup.
#
# So: null omits the key and lets allauth decide, "none" states the public
# client explicitly - which is what some providers need spelled out since
# the allauth update in paperless-ngx 3.0, and the symptom when they do is
# that same invalid_client at the callback. If a future paperless release
# supports an auth method that needs no secret, widening the enum is a minor
# bump; allowing one that needs a secret is not on the table.
# Optional: explicit token endpoint auth method (e.g. client_secret_basic).
# Some providers need this since the allauth update in paperless-ngx 3.0
# (login fails at the callback with invalid_client otherwise).
tokenAuthMethod: null
# ---------------------------------------------------------------------------
@@ -540,134 +424,3 @@ valkey:
# And one rule that is about this repository rather than about an installation:
# changes to valkey/ must not share a pull request with changes to this chart.
# Chart.yaml says why; README and the wiki's valkey page carry the long form.
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# WHAT THIS POLICY ALLOWS: inbound 8000 only, and outbound DNS plus TCP 5432
# (Postgres), 6379 (Redis/Valkey), 443, and - only while
# paperless.files.tikaGotenberg.enabled is true - 9998, 3000 and 80 for Tika
# and Gotenberg.
#
# WHY THAT RULE NAMES 9998/3000 AND NOT JUST THE SERVICES' 80 (Gitea #136, an
# outage on 2026-10-15). The note that used to stand here said:
#
# "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 ports."
#
# That is wrong, and it is kept as a quotation because it sounds right - which
# is why it was never checked. kube-proxy does the ClusterIP Service's
# destination NAT BEFORE the egress policy is evaluated at the pod's network
# interface, so the policy sees podIP:targetPort, never clusterIP:port. An
# egress rule therefore names the POD port: 9998 for apache-tika, 3000 for
# gotenberg, both read from those charts' containerPorts. The 80 stays because
# it costs nothing and covers a foreign Tika that really does listen on 80.
#
# AND THE FAILURE MODE IS A TIMEOUT, NOT "connection refused": a missing
# service refuses, a dropped packet runs into the timeout. 5432, 6379 and 443
# were unaffected only because their Service port and pod port are the same
# number.
#
# THREE THINGS IT DOES NOT COVER:
# - The VALKEY SUBCHART'S POD. It carries its own labels, so this policy
# does not select it - and it does not need to: the valkey chart ships a
# NetworkPolicy of its own, and a tighter one.
# - The CONSUME CRONJOB'S PODS. Their pod template carries NO LABELS, so no
# podSelector can match them and nothing here restricts them. Tolerable
# only because the job moves files between mounted volumes and the SMB
# share arrives as a CSI volume mounted by the NODE - the pod opens no
# connection of its own. If that changes, the CronJob needs labels first.
# - MAIL FETCHING, unless egress.mailImport is switched on. Paperless' mail
# accounts are rows in its database, configured in the web UI, so this
# chart cannot see them and cannot derive the rule. Left off on a release
# that does fetch mail, a mail rule silently stops importing.
#
# And the usual two: 5432 and 6379 are the STANDARD ports rather than the
# configured ones, and a policy is only a boundary where the CNI enforces
# one.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach port 8000, which is the port all three probes address.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
egress:
# Outbound IMAPS (993) for paperless' mail import. OFF, and it has to be a
# value rather than something the chart derives: the mail accounts are ROWS
# IN THE DATABASE, entered in the web UI, so nothing in these values says
# whether a release fetches mail. Switching this on is the operator saying
# that it does.
#
# 993 ONLY, deliberately. Plain IMAP (143) and POP3 (110, 995) are not
# covered - a value named mailImport that opened four ports would be wider
# than its name - and belong in extraPorts below.
#
# NOTE THE DIFFERENCE FROM THE TIKA RULE: 993 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. What decides whether an egress rule
# needs the Service port or the pod port is that NAT, and nothing else.
mailImport: false
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# PLAIN IMAP (143) and POP3 (110, 995) go here - IMAPS 993 has its own
# switch above. Also a Postgres or Redis on a non-standard port, and a Tika
# or Gotenberg that listens on neither its chart's container port nor 80.
#
# These are POD ports for anything inside the cluster, for the reason given
# at the top of this block: a ClusterIP Service's port is translated away
# before the policy sees the packet. For a destination outside the cluster
# there is no translation and the port is simply the one being dialled.
extraPorts: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/PatchMon/PatchMon/main/frontend/public/a
type: application
version: 8.0.0+up2.1.3
version: 7.1.0+up2.1.3
appVersion: "2.1.3"
maintainers:
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more