Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6df877224
|
No files matched your search
@@ -42,8 +42,6 @@ on:
|
||||
- valkey
|
||||
- stalwart
|
||||
- garage
|
||||
- keycloak
|
||||
- bulwark
|
||||
# freesailarr is deliberately absent: none of its ten images takes
|
||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||
# so this workflow would rewrite appVersion and republish the chart
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Bulwark Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- bulwark/**
|
||||
jobs:
|
||||
publish-bulwark:
|
||||
name: Publish Bulwark Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'bulwark'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Keycloak Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- keycloak/**
|
||||
jobs:
|
||||
publish-keycloak:
|
||||
name: Publish Keycloak Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'keycloak'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -173,192 +173,6 @@ jobs:
|
||||
- name: "Render every chart with bare integer quantities"
|
||||
run: ci/run-quantity-render-test.sh
|
||||
|
||||
selector-match:
|
||||
name: "Selector match"
|
||||
runs-on: ubuntu-latest
|
||||
# The second job that ignores the changed-chart matrix and runs over *every*
|
||||
# chart (Gitea #96), and for the same reason as quantity-render above: the
|
||||
# mistake arrives by copy into a chart whose own diff nobody reads for it.
|
||||
#
|
||||
# What it asserts: every PodDisruptionBudget selector and every
|
||||
# topologySpreadConstraints labelSelector a chart renders really matches the
|
||||
# labels of a pod template in the SAME render. Both objects fail SILENTLY
|
||||
# when their selector matches nothing - the spread constraint becomes a
|
||||
# condition over zero pods and is trivially satisfied, the budget does its
|
||||
# arithmetic over zero pods and never blocks an eviction - so neither a
|
||||
# render error nor the install-test can catch it, and with
|
||||
# whenUnsatisfiable: DoNotSchedule the object reads like a guarantee while
|
||||
# distributing nothing.
|
||||
#
|
||||
# It needs no cluster and no Trivy: ~14 seconds for the whole repository,
|
||||
# depends on no other job, and finishes well inside build-chart. The YAML is
|
||||
# parsed by Helm itself through ci/yaml2json, because the runners have
|
||||
# neither pyyaml nor yq and a hand-written parser would be able to 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 selector against the rendered pod labels"
|
||||
run: ci/run-selector-match-test.sh
|
||||
|
||||
httproute-match:
|
||||
name: "HTTPRoute match"
|
||||
runs-on: ubuntu-latest
|
||||
# The third job that ignores the changed-chart matrix and runs over *every*
|
||||
# chart (Gitea #114), and for the same reason as the two above: the mistake
|
||||
# arrives by copy into a chart whose own diff nobody reads for it - and this
|
||||
# convention is about to be copied into 20 more charts when the sweep runs.
|
||||
#
|
||||
# What it asserts: every HTTPRoute a chart renders names a Service, on a
|
||||
# port, that the SAME render creates, and its parentRefs is not empty. Both
|
||||
# failures are SILENT and neither is reachable from the other checks:
|
||||
# parentRefs: [] attaches the route to no Gateway (created, schema-valid,
|
||||
# routes nothing, no event and no condition), and a backendRef is resolved
|
||||
# by name with nothing validating that the name exists. The real Gateway-API
|
||||
# CRDs in the install-test prove the object is WELL-FORMED; they accept both
|
||||
# of these, because an empty list and a dangling name are valid YAML
|
||||
# against that schema.
|
||||
#
|
||||
# What is NOT provable anywhere, and is written down rather than implied: no
|
||||
# Gateway controller runs in CI, so no route is ever reconciled. That
|
||||
# traffic flows is not part of this pipeline's claims.
|
||||
#
|
||||
# No cluster and no Trivy; ~3 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 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
|
||||
|
||||
@@ -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,14 +16,12 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
|---|---|---|
|
||||
| `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
|
||||
| `bentopdf` | BentoPDF | Stateless Deployment + `config.json` ConfigMap, `subPath`-mounted into the web root and always rendered (empty renders as `{}`) |
|
||||
| `bulwark` | Bulwark Webmail (JMAP client for Stalwart) | **The companion to `stalwart`, and a pure client**: no mail, no database, no cache, no broker — the messages belong to the Stalwart release named by `bulwark.jmapServerUrl`. StatefulSet with **one** PVC at `/app/data`, carrying all four of Bulwark's directories (admin config, admin state with the audit log, encrypted user settings, telemetry) as subdirectories — one volume because snapshots are atomic per volume and never across them (the `garage` lesson, #81). No ConfigMap, so no `checksum/config` (§9 has nothing to attach to); the `op://` paths are covered by `checksum/secret-ref`. **The defining property, and it governs every value: the VOLUME BEATS THE CHART.** `configManager.get` resolves `admin override > env var > default`, the admin dashboard may write *any* key, and nothing says it just disconnected the chart — so a `helm upgrade` with a changed value can succeed and change nothing. Upstream documents this only for the server URL; it is true of everything. Exactly three values escape it (`SESSION_SECRET(_FILE)`, `ADMIN_PASSWORD`, `BULWARK_TELEMETRY`), and `bulwark.adminConfigReadOnly` closes it at a price that is **threefold and measured**: it freezes the settings policy (and with it the only way to move Web Push off the vendor relay), it freezes an override that *already* exists rather than removing it, and on a **fresh** install it stops `admin.json` from being written at all — so the admin dashboard never comes up and answers HTTP 500 while the pod stays Ready. Hence the default `false`. Sign-in is **OIDC-only** (`oauth.only`), and the way back out of a lockout is **not** `helm upgrade` with it `false` — `oauthOnly` is read through the same precedence chain, so the repair is removing the override in the **admin dashboard**, which is why `ADMIN_PASSWORD` is set at all (it generates no random password and logs nothing — the `#65` analogy does not hold here). Its **redirect URI carries the UI language** (`/<locale>/auth/callback`, 27 locales, and the language is not in the visible URL), so a single registered URI is the likeliest cause of a failed first sign-in. Both `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` exist and are used — the better path `garage` and `keycloak` did not have — mounted **without `subPath`** so that a rotation takes effect without a restart, since both are re-read on every use. Two backup statements, both sharp: without the session secret the synchronized settings are unreadable **out of an intact volume**, and if the dashboard's OAuth setup was ever used the client secret sits in `config.json` on that volume in **cleartext**. All three probes are a plain GET on `/api/health` — **not** HEAD, which a `HTTPGetAction` cannot send — and the limit is written at the value: it carries process, HTTP acceptance, a free event loop and Next.js routing, and **nothing** about Stalwart, so with the mail server gone the pod is green. Served only at a host **root** (`NEXT_PUBLIC_BASE_PATH` is compiled in), one replica (single RWO PVC), two emptyDirs for `readOnlyRootFilesystem` (`/tmp`, `/app/.next/cache` — image optimization is active because `images.unoptimized` is in the lite branch only) |
|
||||
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
|
||||
| `cyberchef` | CyberChef | Stateless Deployment. **The Gateway-API pilot (#114)**: the first chart offering `httpRoute.enabled` as the alternative to `ingress.enabled` — `HTTPRoute` with `parentRefs` only, no Gateway and no certificate, the two modes mutually exclusive. It is also the only chart whose install-test runs in Gateway mode, because that test installs one configuration per chart |
|
||||
| `cyberchef` | CyberChef | Stateless Deployment |
|
||||
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, and mediathekarr off by default) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service per app (mediathekarr's carries both of its ports); no global `ingress.domain`. Ingress in two shapes that mix: one host per app (default, each app's host required), or one shared `ingress.host` with the apps as sub-paths (documented ingress exception below) |
|
||||
| `garage` | Garage (S3-compatible object store) | **One** node, `replication_factor = 1` — a deliberate trade with its price named (Gitea #81): the volume layer underneath already replicates and encrypts, so Garage replication on top would be nine times the raw data across two layers that know nothing about each other. What that costs is that the factor applies to the **metadata** too, where a corrupted database is upstream-documented as "generally not recoverable" and a replicated volume replicates the corruption. Hence three settings that are mandatory rather than optional: `metadata_fsync = true` (**off** in Garage's defaults), the automatic metadata snapshots, and those snapshots on a **second PVC** — a file-system snapshot of a live metadata database is not a restore point, so the volume backup covers the blocks and not the index. The first PVC carries `metadata_dir` **and** `data_dir` as two subdirectories, because volume snapshots are atomic per volume: that is what keeps index and blocks consistent with each other. Two further properties shape the whole chart: the image is **`FROM scratch`** and contains exactly one file, `/garage` — no shell, so **exec probes are impossible here** and an init container would need a second image — and the RPC secret has to arrive as an **environment variable**, because `rpc_secret_file` rejects any file with a group bit set while the kubelet sets one (the reasoning sits at the value). Started with `garage server --single-node`, which creates the cluster layout in-process before any listener opens and is idempotent on the layout version, and with `--default-access-key`/`--default-bucket`, which **import** key and bucket from the 1Password item — so the vault is the source of truth instead of a copy of what the server generated. Readiness is the admin API's `/health`, liveness and startup deliberately are **not**: `/health` answers 503 until a layout exists, so a node waiting for one would be restarted forever |
|
||||
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
|
||||
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
|
||||
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses — plus one routing object per `keycloak.realmHosts` entry (Gitea #122), empty by default, which gives a realm a hostname of its own by sending that hostname's bare `/` to `/realms/<realm>/account/`: `app-root` in Ingress mode, an `Exact` match with a `RequestRedirect` filter in Gateway mode. Those entries carry a **required** per-entry TLS secret name and an **optional** per-entry issuer, the documented deviation in §4, because the hostnames they publish already have certificates and often share one. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
|
||||
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
|
||||
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
|
||||
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java<N>` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: <release>-java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart |
|
||||
@@ -31,11 +29,11 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects). Configured as a **queue** (PVC + `noeviction`), unlike `stalwart`'s cache: `PAPERLESS_REDIS` is the Celery broker, and `allkeys-lru` evicts across the whole keyspace, where a backlog of waiting consume tasks is exactly what LRU reads as cold. The password comes from the **same 1Password item** as the Redis connection, in a third field `auth-conf` holding the complete line `requirepass <password>` — so the password sits in that item **twice** and a rotation has to touch both fields. Two more traps: with the subchart on, `redis-connection-url` in the vault **must** be repointed at `<release>-valkey-service:6379` or the bundled Valkey runs unused, and `valkeySubchart:` must not be erased — a null override deletes the key, Helm reads an unresolvable `condition:` as *enabled*, so erasing the switch turns the subchart **on** (rejected by `values.schema.json`). Never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
|
||||
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
|
||||
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
|
||||
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) — required only while `ingress.enabled`, since in Gateway mode the names come from `httpRoute.hostnames` and the secret grouping has nothing to group. **The last chart of the Gateway-API sweep (#114)**. Its install-test had failed on the Gitea runner since the move (#51) and the chart was deliberately **not** excluded so that a pass would be noticed — it passed in the conversion PR, on a render byte-identical to `main`, which rules the chart out and leaves the runner |
|
||||
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
|
||||
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
||||
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
|
||||
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
|
||||
| `stalwart` | Stalwart mail server (SMTP, IMAP, POP3, JMAP, ManageSieve) | A webmail client for it is the separate `bulwark` chart (above), pointed at this release's JMAP endpoint — the two are only useful together, and nothing in either reports the other being away. **The chart configures almost nothing, and that is the chart's defining property.** Since v0.16 Stalwart keeps its whole configuration — every listener, the blob store, the in-memory store, TLS objects, domains and the cluster roles — inside the data store and manages it over JMAP; the only file on disk is a `config.json` that names the data store and nothing else. So the chart renders that ConfigMap (secret-free: `authSecret` uses the `EnvironmentVariable` variant, so the password arrives by `secretKeyRef` and `checksum/config` stays meaningful), one StatefulSet per entry of the `nodes` **map** with `STALWART_ROLE` and `STALWART_HOSTNAME`, one NodePort Service **per port** (not per group — a NodePort is cluster-unique, so `full` and `mx` can only take SMTP behind the same Service, selected by a per-port pod label), a per-group ClusterIP `http` Service for the Ingress, per-group headless Services and NetworkPolicies, and a cert-manager `Certificate` for the mail ports. Everything else is operator work in the WebUI, which is why `NOTES.txt` carries an eight-step first install. Four traps worth knowing before reading the chart: **the role lookup fails open** — a node whose `ClusterRole` is missing from the database enables every listener *and* every task while staying Running with a green probe, so `mx` ships at `replicas: 0` and the NetworkPolicy is a ceiling independent of what was bound; **`NET_BIND_SERVICE` is a documented deviation needed to `exec`, not to bind** (the image sets `setcap cap_net_bind_service=+ep`, so `drop: [ALL]` alone makes the kernel fail `execve` — removing it as "unused" stops the chart starting); **`externalTrafficPolicy` must be `Cluster`** (no pod runs on a control-plane node while the HA proxy delivers to them, so `Local` would drop every packet) and the source address it costs is restored only by the HA proxy's PROXY v2 header; and **`/healthz` is rate-limited to 100 req/60s per source IP**, which is a node IP for kubelet probes, so the probe intervals are calculated rather than chosen. A first install needs `bootstrap.enabled: true` because the setup wizard appears only when `config.json` is *absent*. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects); never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
|
||||
| `stalwart` | Stalwart mail server (SMTP, IMAP, POP3, JMAP, ManageSieve) | **The chart configures almost nothing, and that is the chart's defining property.** Since v0.16 Stalwart keeps its whole configuration — every listener, the blob store, the in-memory store, TLS objects, domains and the cluster roles — inside the data store and manages it over JMAP; the only file on disk is a `config.json` that names the data store and nothing else. So the chart renders that ConfigMap (secret-free: `authSecret` uses the `EnvironmentVariable` variant, so the password arrives by `secretKeyRef` and `checksum/config` stays meaningful), one StatefulSet per entry of the `nodes` **map** with `STALWART_ROLE` and `STALWART_HOSTNAME`, one NodePort Service **per port** (not per group — a NodePort is cluster-unique, so `full` and `mx` can only take SMTP behind the same Service, selected by a per-port pod label), a per-group ClusterIP `http` Service for the Ingress, per-group headless Services and NetworkPolicies, and a cert-manager `Certificate` for the mail ports. Everything else is operator work in the WebUI, which is why `NOTES.txt` carries an eight-step first install. Four traps worth knowing before reading the chart: **the role lookup fails open** — a node whose `ClusterRole` is missing from the database enables every listener *and* every task while staying Running with a green probe, so `mx` ships at `replicas: 0` and the NetworkPolicy is a ceiling independent of what was bound; **`NET_BIND_SERVICE` is a documented deviation needed to `exec`, not to bind** (the image sets `setcap cap_net_bind_service=+ep`, so `drop: [ALL]` alone makes the kernel fail `execve` — removing it as "unused" stops the chart starting); **`externalTrafficPolicy` must be `Cluster`** (no pod runs on a control-plane node while the HA proxy delivers to them, so `Local` would drop every packet) and the source address it costs is restored only by the HA proxy's PROXY v2 header; and **`/healthz` is rate-limited to 100 req/60s per source IP**, which is a node IP for kubelet probes, so the probe intervals are calculated rather than chosen. A first install needs `bootstrap.enabled: true` because the setup wizard appears only when `config.json` is *absent*. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects); never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
|
||||
| `sure` | Sure (we-promise, the maintained Maybe-finance fork) | One StatefulSet with **two containers**: the Puma web process and a Sidekiq worker, sharing one ReadWriteOnce PVC at `/rails/storage` — that shared volume is the reason they live in one pod. Only the web container migrates (the image entrypoint runs `db:prepare` solely for the `./bin/rails server` command, so the worker is started through `args:` and skips it). Three 1Password items (app/database/redis); Postgres and Redis stay external. Readiness deliberately probes `/` rather than Rails' `/up`, which is a boot check and touches no database; the worker's three probes ask `sidekiqmon` for this pod's own heartbeat. `ingress.domain` is read for `APP_DOMAIN` and the WebAuthn relying party, so it stays required with the Ingress switched off |
|
||||
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
|
||||
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
||||
@@ -68,8 +66,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 **five** 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` 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** is the exception to that rule: it runs 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.
|
||||
@@ -142,113 +140,6 @@ Two details worth knowing before touching it:
|
||||
- **The resources paths come from `values.schema.json`, not `values.yaml`** — it is JSON, so `python3` reads it without pyyaml (same runner constraint as `ci/run-trivy-config.sh`), and every chart-owned key must appear there anyway. A block counts when it is `#/definitions/resources` or carries `limitFactor`/`requests` itself, which is what keeps `minecraft-server`'s JVM-sizing `minecraft.resources` out. A chart whose templates render a resources block while its schema declares none **fails** the test with exactly that message: the schema is then out of sync with the chart.
|
||||
- **A chart that never reaches the helper is reported, not counted as passing.** `gotenberg` sets every computable limit explicitly in its fallback defaults, and since a `null` never deletes a default there, no override can free them — nothing is multiplied, so nothing is proven. Those charts appear under "helper not reached" in the job summary rather than as a green tick for a code path that was never entered.
|
||||
|
||||
### Selector-match render test (all charts, every PR)
|
||||
|
||||
`ci/run-selector-match-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and checks that **every label selector the render contains really matches the labels of a pod template in that same render**. Two kinds of object are covered, and both of them fail *silently* when their selector matches nothing:
|
||||
|
||||
| Object | What a non-matching selector does | Who notices today |
|
||||
|---|---|---|
|
||||
| `topologySpreadConstraints` | the constraint is a condition over **zero** pods and is therefore trivially satisfied: nothing is distributed, no event, no log line | nobody — and with `whenUnsatisfiable: DoNotSchedule` the object reads like a guarantee |
|
||||
| `PodDisruptionBudget` | the budget does its arithmetic over zero pods and never blocks an eviction, so `kubectl drain` takes every replica at once | nobody — `kubectl get pdb` shows an object that looks like protection |
|
||||
|
||||
Neither is a render error, neither fails the install-test, and neither shows up in the diff of the PR that causes it: the chart that breaks it is usually a chart that **renamed a label somewhere else**. Charts answer the first half of that by keeping the selector and the pod labels in one helper (`stalwart.groupLabels`, `keycloak`'s `app` label) — but one definition only helps while nobody writes a second one, and that is the half a test can hold. Introduced with Gitea #96, where the chart-owned spread constraint made the trivially-satisfied case reachable by design rather than by accident.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### HTTPRoute render test (all charts, every PR)
|
||||
|
||||
`ci/run-httproute-match-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and checks every `HTTPRoute` in the result against the `Service` objects of that **same** render. Introduced with the Gateway-API pilot (Gitea #114); three failure modes, all silent:
|
||||
|
||||
| Finding | What it does | Who notices today |
|
||||
|---|---|---|
|
||||
| `parentRefs` empty | the route attaches to **no** Gateway: created, schema-valid, rejected by nobody, and it routes nothing | nobody — no event, no condition, the only symptom is a hostname that does not answer |
|
||||
| `backendRefs` naming a Service or a port that does not exist | the reference is resolved **by name** at runtime and validated by nothing | nobody in CI — `ResolvedRefs: False` lands in a route status that only a Gateway controller writes, and none runs here |
|
||||
| a prefix stripped with `ReplacePrefixMatch` while the same route carries no `Exact` match on that prefix with a `RequestRedirect` filter (Gitea #118) | the **slashless** form of the path is rewritten to `/` and answered **200** by the app, under a document URL that keeps the prefix off — so every relative link in that page resolves against the host root, which on a shared host is a *different* application | nobody — the app's own assets and XHRs are served by its neighbour, and the symptom is a half-working UI, not an error |
|
||||
|
||||
**The real CRDs in the install-test close none of these gaps**, which is the whole reason this job exists next to them: the Gateway-API schema happily accepts an empty `parentRefs` list, a `backendRefs` entry pointing at a Service that was never created, and a prefix-stripping rewrite with no redirect beside it. The CRDs prove the object is *well-formed*; this test proves it *points somewhere*, and *somewhere right*. The port half is a genuine new exposure rather than a hypothetical: an Ingress backend references its Service port **by name**, while `backendRefs` takes a `PortNumber` and cannot, so the number in the route and the port in the Service are kept in step by hand from now on.
|
||||
|
||||
The third check is a **pairing within one HTTPRoute object**, and the scope is deliberate: Gateway-API matching precedence is a property of a single route, so a redirect in a *different* route (a different hostname) says nothing about this one. Precedence also makes the pairing a question of existence rather than order — an `Exact` path match outranks every prefix match, so the redirect rule wins wherever it is emitted, and the rules' length-descending order stays a rendering convention. `freesailarr` is the first chart it applies to — **and, after group 3, still the only one.** The line that used to stand here said `bulwark` was the second `rewrite-target` chart and would meet the same check when group 3 landed. Group 3 landed and it does not: **`bulwark` has one Ingress object, no `rewrite-target`, and cannot have one** — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path at all and its route is one rule at `/` with no `URLRewrite` filter. Every occurrence of the string `rewrite-target` in that chart is an explanation of *why it does not help*, and #114's measured table arrived at "`bulwark`, `freesailarr`" by grepping for the string rather than for the annotation. The check still runs over all charts for the reason below — the mistake arrives by copy into a chart whose own diff nobody reads for it — not because a second chart is known to need it.
|
||||
|
||||
Three things to know before touching it:
|
||||
|
||||
- **Helm is the YAML parser**, through `ci/yaml2json`, for the same reason as the selector-match test above — the runners have neither `pyyaml` nor `yq`, and a parser written in `awk` fails by reading nothing and reporting nothing.
|
||||
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing more: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart, and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; both verdicts are correct at once.
|
||||
- **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/<chart>.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered.
|
||||
|
||||
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. Group 3 took it to `56` assertions over `21` charts, from six charts of which three contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total was therefore read off the first branch that contained both — the realm-hostname branch rebased onto this one — and it is **`62` assertions over `22` charts**, `0` failed. `keycloak` contributes `8` of them: one `parentRefs` and one `backendRefs` assertion for each of its four routes, the two realm routes adding one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on those two: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path). **The MTA-STS routes of Gitea #123 add `2`**, and they are the entry that shows what this figure means once the sweep is over: `stalwart` goes from `2` assertions to `4` — one `parentRefs` and one `backendRefs` assertion for its new MTA-STS route — and **the chart count does not move at all**. Every earlier entry in this history grew because a chart *joined* the list; this is the first that grew because a chart already in it gained an **object**, which is the ordinary shape of the next one too. Following the same delta rule, and read off the first run that contains all three rounds — this branch, rebased onto a `main` that already held `rss-bridge` and the realm hostnames — the figure is **`64` assertions over `22` charts**, `0` failed. The delta counts only because the overlay was extended to enter the new shape: `ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one host on a **subdomain**. An overlay left alone would have added a second route this test never rendered — the anti-vacuum rule biting on a *second* object inside a chart that already passes, which no per-chart "has an overlay" check can see. **The Ingress half of that feature is not covered here at all**, because an overlay's one job is to switch Gateway mode on; it sits in `ci/stalwart/test-values.yaml`, where the install-test has a real API server accept it.
|
||||
|
||||
**And in the same PR that chart's install-test went green, which is the event Gitea #51 was waiting for.** It had failed on the Gitea runner for months (uid 33 unable to read `/app/index.php`, startup probe 403 through its whole budget), and the chart was deliberately kept *out* of `ci/excluded-charts.txt` precisely so that the day it passed would be noticed rather than silenced. It passed: k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no `Permission denied` anywhere in the job. **The chart cannot be the reason, and that is measured rather than argued** — the rendered output for the fixture values is byte-identical to `origin/main`, so the pod spec that installed is exactly the one `main` produces. The implicated layer is the runner, which is where #51 had already put the burden of proof. Two cautions belong with the result: **nobody has identified what changed on the runner**, and a green test does not explain itself — it has reproduced on every run of that branch since, across several commits, so it is not a fluke - but that is still a colour and not a cause; and the install covers **Ingress** mode, since that is what the fixture installs — so this chart's Gateway mode stays render-only, exactly like its 21 siblings rather than worse than them.
|
||||
|
||||
The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
|
||||
|
||||
### 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.
|
||||
@@ -258,7 +149,6 @@ The job itself is the reusable workflow **`install-test.yml`**; `validate-charts
|
||||
Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the chart directories, so they are neither packaged into the chart tgz nor trigger the publish workflows on merge):
|
||||
|
||||
- `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`)
|
||||
- `ci/gateway-api-crds.sh` — the **real** Gateway-API CRDs (standard channel, pinned, Renovate-tracked), applied only for a chart whose render actually contains an `HTTPRoute`. **Deliberately not a stub and deliberately not in `ci/common/`.** A schema-less stub would make the exercise worthless here: the API server would accept a malformed `parentRefs`, a port written as a string, a path type that does not exist, and the install-test would prove only that *something was created* — for `OnePasswordItem` that is the right trade (nobody is testing the operator's API), for an `HTTPRoute` the schema **is** the point. Applied with `kubectl apply --server-side`, and the reason is measured: client-side apply stores the whole object in the `last-applied-configuration` annotation, annotations total at most 262144 bytes, and the HTTPRoute CRD of v1.6.3 is 244792 bytes as JSON — 93 % of the limit, with every release adding fields. Client-side happens to fit today and is one upstream release away from failing with `metadata.annotations: Too long` on a Renovate PR that has nothing to do with it. Downloaded rather than vendored, like Trivy's tarball and `kubectl`: 1.1 MB of generated YAML in the repo would go stale invisibly, and the price of the fetch is named — if the asset disappears, an unrelated PR goes red, loudly
|
||||
- `ci/<chart>/test-values.yaml` — values for the CI install (required for every installable chart)
|
||||
- `ci/<chart>/fixtures.yaml` — dummy secrets with in-cluster connection data, throwaway infrastructure (postgres/redis/dex), static hostPath PVs/StorageClasses where the chart pins a storage class or needs RWX, and seed jobs (e.g. `synapse generate`)
|
||||
- `ci/<chart>/fixtures.env.yaml` — fixtures that need a value which must not be committed. The file holds `${VARIABLE}` placeholders and nothing else; `ci/run-install-test.sh` renders it with `envsubst` and pipes the result straight into `kubectl` (never printed). Substitution is restricted to the names from `REQUIRED_ENV`, so an undeclared variable is never filled in and no unrelated `$…` in the manifest is touched
|
||||
@@ -321,7 +211,7 @@ What Renovate keeps current:
|
||||
| --- | --- |
|
||||
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
|
||||
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block. All ten arrive **grouped into one PR**, see "The one grouped chart" below |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block |
|
||||
| GitHub Actions | Built-in `github-actions` manager |
|
||||
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
|
||||
|
||||
@@ -329,51 +219,23 @@ 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.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**linuxserver/qbittorrent** (freesailarr) excludes two tags by name, `14.3.9` and `20.04.1`. They are leftovers from earlier linuxserver tagging schemes, and docker versioning sorts both above every real release, so Renovate offers them as major updates — PRs #152 and #153 were exactly that. They are named rather than cut off with an upper bound like `<10`, because a bound would eventually reject a genuine major, while two finished artifacts cannot grow and therefore cannot hide a future release. The same guard as for nginx applies and was run: a lookup from `currentValue 4.6.4` still offers `5.2.3` as a **major**, so the filter does not silently empty the update list. That fixture also shows the cost of leaving it alone — without the rule the very same lookup offers `20.04.1` and never mentions the real `5.2.3`, since Renovate fills the major bucket only once.
|
||||
|
||||
### The one grouped chart
|
||||
|
||||
`freesailarr` is the only chart whose updates are **grouped**: all ten of its images arrive as one PR per cycle (`groupName: "freesailarr stack"`), where every other chart in this repo gets one PR per image. For the others that is already the same thing — one chart, one image — so this is a rule about a ten-image chart rather than a change of policy. It is also the only `groupName` in `renovate.json5`, and it reverses what the comment above the app-version managers describes: the `helm-values` manager finding those ten images and proposing "one update per image". That sentence described what Renovate does, not what the chart wants.
|
||||
|
||||
**What it fixes, measured on 2026-10-12.** Eight of the ten images had an open Renovate PR simultaneously (#102 bazarr, #103 qbittorrent, #104 sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr). Each edits `freesailarr/values.yaml` and — through the `postUpgradeTasks` hook — `freesailarr/Chart.yaml`, so the API reported `mergeable: false` for all eight: they rewrite the same `version:` line. One could merge; the other seven then needed a rebase each, and every rebase restarts the most expensive install-test here (a real Wireguard tunnel, ten containers). A sharper second symptom came with it: all eight carried `version: 3.1.1+up1.0.0 → 3.1.2+up1.0.0` while `main` already stood at `3.3.0+up1.0.0`, because the hook derives everything from the **base commit** and those branches predated the Gateway-API rounds. A rebase repairs that, which is precisely the problem — nothing *fails* if the rebase does not happen first, so the repair depends on the bot reaching the branch before a human reaches the merge button. Grouped, there is one branch, one `version:` computed from one base, one publish and one install-test run.
|
||||
|
||||
**Four decisions worth keeping** (the reasoning sits next to the rules in `renovate.json5`):
|
||||
|
||||
- **Matched by file name, not by a list of images.** What causes the collision is "touches `freesailarr/Chart.yaml`", true of every dependency Renovate finds in that `values.yaml` regardless of manager or datasource. A `matchPackageNames` list would be a second place to keep the chart's image set current, and its failure mode is silence — an image added later never joins the group, with no error (#158: a valid rule is not a matching rule).
|
||||
- **`mediathekarr` is in the group** although it comes from its own `customManagers` block: its digest PR edits the same two files and blocks the others identically. `digest` is in `matchUpdateTypes` for exactly that, and its own block is untouched — a group decides which *branch* an update lands on, not how it is found.
|
||||
- **`gluetun` and the `busybox` init image are in too.** Neither "gluetun is the tunnel" nor "busybox is only an init container" has anything to do with the blocking, and a grouped PR hides nothing: the review still sees every changed line, just not eight times over.
|
||||
- **Majors stay out**, via `matchUpdateTypes: ["patch", "minor", "digest"]`. An app major must be taken stepwise in its own PR, and `separateMultipleMajor` splits a multi-major jump to produce that sequence; a group throwing a major in with seven patches would undo both.
|
||||
|
||||
**It takes two `packageRules`, and that is not cosmetic.** Renovate refuses `matchUpdateTypes` and `separateMinorPatch` in one rule — `packageRules cannot combine both matchUpdateTypes and separateMinorPatch`, a config-validation error — which is consistent, since `separateMinorPatch` is what *decides* the split a rule would then be selecting on. So one rule groups the non-major update types and a second sets `separateMinorPatch: false` for the same file. Without that second rule the group still arrives as **two** PRs: verified by dry-run (`--platform=local --dry-run=full`, real registries) against a checkout holding those eight updates, they landed on `renovate/freesailarr-stack` (the three minors) and `renovate/patch-freesailarr-stack` (the five patches) — note that `separateMinorPatch` **prefixes** the branch rather than suffixing it, which makes the second name easy to miss in a branch list. With both rules, one branch carries all eight. The global `separateMinorPatch: true` keeps its reason for every other chart; "one red install-test must not block unrelated updates" simply does not apply within a single chart, where the updates share a pod, a chart version and a publish.
|
||||
|
||||
What this cannot show from inside the repo: Renovate runs as the shared instance outside it, so a valid config and a correct local lookup are the evidence available — not the behaviour of the next real run.
|
||||
|
||||
Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to major lines on purpose) and `template-chart/**` (placeholder image). A consequence worth knowing: `ci/matrix-synapse/fixtures.yaml` pins a Synapse image that Renovate will not follow — bump it by hand when the chart moves a major.
|
||||
|
||||
### 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 +244,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.
|
||||
|
||||
@@ -404,7 +264,7 @@ The config is aligned with the one in `cluster-config`, because the same `jlab-r
|
||||
|
||||
- **Automerge is global** (`automerge: true`, `automergeType: "pr"`, `platformAutomerge: true`) — every update type, **majors included**. The safeguard is not the absence of automerge, it is the branch protection: `main` requires a **review** and requires `validate-charts` to pass. `platformAutomerge` arms the PR on the forge, and Gitea merges it when *both* conditions are met, so an armed PR without a review simply sits there. This is the setting the older `platformAutomerge: false` comment in `renovate.json5` named as the better one "once required checks exist on `main`" — they now do. Same reasoning as `cluster-config`, which retired its dashboard-approval step for it.
|
||||
- **`minimumReleaseAge: "3 days"`** for `patch`, `minor` and `digest` updates. Renovate does not even open the PR before then. A merge here is never just a merge — a chart publish follows it straight away — so a release that gets yanked hours after it appeared must not reach the registry. Majors carry no age gate: they are held at the review, not by a delay.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates. **It has one exception**, `freesailarr`, where it is overridden to `false` — see "The one grouped chart" below for why that separation is wrong inside a single chart.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates.
|
||||
- **No PR caps** (`prConcurrentLimit: 100`, `prHourlyLimit: 0`), as in `cluster-config`. The known cost, written down rather than discovered: the first run after a quiet week can open many PRs at once, and every chart PR starts a k3d install-test — which on a Gitea runner queues inside one job rather than fanning out (see "Where this repo lives"). That queue is the accepted trade for not having updates trickle in four per hour.
|
||||
- **`config:best-practices`** is in `extends`. Two of its members matter here: `helpers:pinGitHubActionDigests` moves the workflow files from `actions/checkout@v7` to SHA pins (wanted), and `docker:pinDigests` is switched off again for `custom.regex` and `helm-values` — an `appVersion:` is a tag by construction (`<repo>:{{ .Chart.AppVersion }}`, and the `+up` suffix is built from the same string), so a digest has nowhere to go. Sidecar digests in `values.yaml` *could* be pinned; that is a separate decision, since each digest change would be a chart patch plus a publish.
|
||||
- The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR.
|
||||
@@ -444,11 +304,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
|
||||
@@ -458,64 +317,15 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
- **Documented exception — minecraft-server**: the only chart whose main image tag can be overridden, through `image.tagOverride` (#158, empty by default — the tag still comes from `appVersion`). `appVersion` is the tag and therefore fixes the JVM, and since chart 5.0.0 only the **newest** Java line is maintained out of this one directory; the older lines stay published as frozen chart versions (java8 and java17 at 4.0.0, java21 at 4.1.0) and never receive another change. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. The override is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach (`renovate.json5` tracks the `appVersion` in `Chart.yaml` and nothing else). The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named — a mismatch used to install silently and only break the container afterwards.
|
||||
- Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round.
|
||||
|
||||
### 4. Ingress and Gateway API
|
||||
### 4. Ingress
|
||||
|
||||
- `ingress.domain` and `ingress.clusterIssuer` (cert-manager) are `required` values **without defaults** — every installation sets both explicitly; they are the only values needed for a default install. `clusterIssuer` is required **only while `ingress.enabled`**: in Gateway mode the chart renders no certificate, and a value that is demanded and does nothing is precisely the defect guideline 12 exists to remove.
|
||||
- `ingress.domain` and `ingress.clusterIssuer` (cert-manager) are `required` values **without defaults** — every installation sets both explicitly; they are the only values needed for a default install.
|
||||
- `ingress.className` selects the ingress class and defaults to `nginx`.
|
||||
- `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets.
|
||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
||||
- **A set of objects publishing ONE backend under hostnames the chart does not own is the third admissible shape, and it buys its secret name and its issuer per entry (Gitea #122).** Where a chart's app serves several tenants from one deployment and each wants a hostname of its own — `keycloak`'s realms are the case this was written for — the hostnames come as a values list and the chart renders one object per entry. Two convention rules do not survive that, and both deviations are **required**, not merely tolerated: the TLS secret name is a per-entry value with **no** default, because these hostnames already have certificates and several entries typically share one (a wildcard for a zone), so a derived `tls-<release>-<chart>-...` name per entry would ask cert-manager for a second certificate for a name that is already covered — one ACME order per hostname, for nothing; and the cert-manager annotation becomes **optional per entry**, because cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry it. That is the same deliberate omission the qBittorrent object in `freesailarr` makes, for the same reason, and it is why the secret name is required rather than defaulted: a chart that invents the name cannot also leave the issuing to somebody else. Annotations are per entry too, since the set is not uniform across the hostnames. Object names carry the **list index**, for the reason spelled out in the homeassistant bullet below — a hostname is not a dependable object-name fragment, so reordering the list renames and recreates the objects — and the entries render in **both** routing modes: a routing value in this repository is mode-independent by design, so a feature rendering in one mode only would silently disappear when an installation switched. That is also why the per-entry `parentRefs` exists, as the counterpart of the per-entry class.
|
||||
- **Documented exception — homeassistant, and it extends to the Gateway mode**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field). Because it has **no `ingress.domain`**, it has **no `httpRoute.hostnames`** either — the second chart without that key after `freesailarr`, and the schema rejects it outright so the absence is enforced rather than merely intended. The hostnames and the paths come from `ingress.hosts`, which is therefore read in **both** modes and is not an Ingress-only value, and the chart renders **one `HTTPRoute` per entry** of that list with one rule per path of the entry. One object carrying several hostnames would be wrong rather than merely different: an `HTTPRoute`'s rules apply to *every* hostname of the object, and per-host paths are this chart's whole point. `ingress.className`, `ingress.tls` and `ingress.annotations` are not read in Gateway mode. Route names carry the entry's **index**, not its hostname (a hostname is not a dependable object-name fragment), so reordering `ingress.hosts` renames routes and therefore recreates rather than edits them — harmless, since a route holds no state and no certificate, but worth knowing before it looks like a bug. Its `pathType` default is the awkward one; see the `ImplementationSpecific` rule in the Gateway-API bullets below.
|
||||
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field).
|
||||
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The rules for the shared shape: the domain is a single value (`ingress.host`), each app's path is its own value defaulting to `/<app>`, the paths render in one Ingress object `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
|
||||
- **The Gateway API is the second routing mode, and it is implementation-neutral by construction (Gitea #114).** A chart offers it through one values block, off by default:
|
||||
|
||||
```yaml
|
||||
httpRoute:
|
||||
enabled: false # counterpart to ingress.enabled
|
||||
parentRefs: [] # the Gateway (name, namespace, sectionName) — a platform object
|
||||
hostnames: [] # empty = [ingress.domain]
|
||||
```
|
||||
|
||||
The rules, each of which is load-bearing:
|
||||
|
||||
- **Only `HTTPRoute` with `parentRefs`.** No `Gateway`, no `gatewayClassName`, no certificate, and **no implementation-specific policy object** (`ClientSettingsPolicy`, `ClientTrafficPolicy` and their equivalents). That subset looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to it is the whole reason the cluster's implementation can be chosen — or replaced — without touching the charts again. A policy object binds a chart to one implementation *and* to a CRD whose presence it cannot check. Class and certificate therefore move to the **platform**: they sit on the Gateway and its listeners, in `cluster-config`.
|
||||
- **A value, not auto-detection.** `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the Gateway-API CRDs fails at install time — so a chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing.
|
||||
- **Mutually exclusive with `ingress.enabled`**, enforced by a `fail` that names **both** value paths. Two paths onto the same Service under one hostname is a conflict at DNS level already; a migration runs over a **second hostname** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never over parallel operation.
|
||||
- **`parentRefs` is mandatory in Gateway mode**, enforced by a `fail` on the empty list. A route without a parent attaches to nothing and is simply inert — created, schema-valid, rejected by nobody, reported by nothing.
|
||||
- **One source of truth for the hostname.** Empty `hostnames` means `[ingress.domain]`; two values that have to agree drift apart. `ingress.domain` is therefore read in Gateway mode too — it names the release's public hostname and is not an Ingress switch. After the sweep there are **three** shapes here, and which one a chart gets follows from its values rather than from taste: a chart with an `ingress.domain` falls back to it (the default, most charts); a chart with no `ingress.domain` whose names could not be attributed — `freesailarr` to one of ten apps, `homeassistant` to one host entry and therefore one set of paths — has **no `hostnames` key at all** and reads the names where they already live; and `rss-bridge` has the key as a **mandatory** value with no fallback, because it has no `ingress.domain` either but its names *are* unambiguous (one Service at `/`), while the `ingress.hosts` list it would otherwise reuse carries per-host `tlsSecretName` values that address nothing once TLS sits on the listener. The rule behind all three: a hostname list is admissible exactly where it can say what it belongs to, and reusing an Ingress structure whose other half is meaningless in this mode is not a saving.
|
||||
- **`NOTES.txt` says which mode is active**, as `keycloak` already does for its cases, and names what the mode costs.
|
||||
- **What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy, so a chart that depends on one must print in its Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway. Charts that *set or recommend* one carry that disclosure: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` since group 2, `bulwark` and `matrix-synapse` since group 3. `garage` *depends* on one without setting it: its `ingress.annotations` comment tells the operator to set `proxy-body-size: "0"` because ingress-nginx's 1 MB default truncates S3 uploads. Measured while sweeping group 1 — "sets no behaviour-bearing annotation" is not the same question as "needs none", and the sweep screens on the second one. `freesailarr` is the mirror image of `garage` and was measured in group 2: it sets no body-size annotation and needs none itself, but `ingress.annotations` is the only lever an operator has for one (qBittorrent takes torrent-file uploads), so its Gateway-mode `NOTES.txt` says that an annotation set there is gone. The same applies to auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all (`cyberchef`, `searxng`), and so does any annotation a chart documents as its only lever for a response header (`bentopdf`: `X-Robots-Tag`).
|
||||
- **Screen the LIVE object, not only the chart — group 3's lesson, and the generalisation of Gitea #118.** A chart can read as correct while the running system fills a gap of the chart with an annotation the **operator** set, and the Gateway mode drops `ingress.annotations`, so it drops the compensation with it. At #118 that was found *after* shipping, on `freesailarr`, where the annotation was a redirect nobody would have looked for in the chart. Group 3 measured it beforehand with read-only `kubectl` against the live cluster, and **four of its six charts carried such an annotation that the chart never sets**: `keycloak` (`proxy-buffer-size: 16k` and `proxy-buffers-number: 4`, on *both* of its Ingress objects), `matrix-synapse` (`proxy-body-size: 50m`, plus the `nginx.org/client-max-body-size` twin for the other nginx controller, plus a `configuration-snippet`), `stalwart` (the whole `enable-cors` block), `bulwark` (`proxy-body-size: 25m`). None of them appears in a `git grep` of this repository, because no template sets any of them — the only hits are the *documentation* that recommends two of them. **The measurement at the live object is part of the procedure, not a flourish**, and reading the chart alone is not a substitute for it.
|
||||
- **And read the annotation's directive before calling it load-bearing.** `matrix-synapse`'s live `configuration-snippet` looks like exactly what Synapse's `x_forwarded` setting depends on — client IP for rate limiting, and federation. It is not: `more_set_headers` is the headers-more module's **output**-header directive ("Replaces (if any) or adds (if not any) the specified output headers"), the request-side one being `more_set_input_headers`. Those headers therefore go on the **response to the client**; Synapse never sees them, and what it reads with `x_forwarded: true` is the *request's* `X-Forwarded-For`, which ingress-nginx supplies itself. So the scariest-looking loss in group 3 is not a loss at all, and its `NOTES.txt` says in as many words **not** to re-create it at the Gateway and not to read its absence as the cause of a rate-limiting problem. The real loss on that chart is the body size. A reading of documented semantics, stated as such — nothing A/B-tested a running Synapse.
|
||||
- **"No counterpart" is a claim to check against the pinned CRDs, and for CORS it is false.** `stalwart`'s live Ingress carries `enable-cors` with an allow-origin pointing at `bulwark`, the webmail chart in the *same group* — so losing it breaks a second chart, with a browser console error and nothing at all in the server's log. Measured by grepping the CRD set this repo pins (`ci/gateway-api-crds.sh`, gateway-api v1.6.3 standard channel): HTTPRoute's filters **do** carry a full `cors` field (GEP-1767) with `allowOrigins`/`allowMethods`/`allowHeaders`/`allowCredentials`/`maxAge`, and its documented level, verbatim from that CRD, is `Support: Extended`. `responseHeaderModifier` is in the same CRD and is **also Extended, not Core**. The chart still renders neither, for three reasons in order: it has **no CORS values** to render one from (the origin and the method list are the operator's, and the chart sets no CORS annotation in Ingress mode either, so a filter would exist in Gateway mode only — an asymmetry that is its own defect and its own piece of work); **Extended support is not support**, since a rejected route is rejected *whole* (the reasoning #119 reached for `statusCode`) and the observed failure is quieter still — Cilium accepts `filters.type: CORS` and forwards the preflight upstream anyway, i.e. `Accepted: True` with the behaviour absent; and **half a CORS is worse than none, with the translatable half being the useless one here** — the browser's preflight must be answered *by the gateway* (the CORS filter's own contract forbids passing it upstream) and a bare header filter cannot do that, while this installation's allowed headers include `Authorization`, which makes **every** request preflighted. Hence: configure CORS at the Gateway with that implementation's own support *before* moving the host, and test from the browser — `curl` sends no preflight, so it passes while the webmail fails.
|
||||
- **`rewrite-target` is the one place where the Gateway API is less machinery than what it replaces, not a translation of it.** `rewrite-target` applies per **Ingress object**; a `URLRewrite` filter applies per **rule**. A chart serving several apps under one host, one of which needs its prefix stripped, therefore needs a *second* Ingress object for that app — which then has to repeat the other object's TLS secret name, omit the cert-manager annotation so only one certificate is requested (conditionally, for the case where no other object renders), and match on an `ImplementationSpecific` regex path whose capture group the rewrite refers to. In Gateway mode all four disappear: one route, one rule per app, the filter on one rule, a plain `PathPrefix`. Measured on `freesailarr` in group 2, and after group 3 it is still the **only** chart here with a `rewrite-target`: `bulwark` was listed as the second one by #114 and by this file, and it has none — see the HTTPRoute-test section for why the string occurs in that chart four times without the annotation ever being set.
|
||||
- **`pathType: ImplementationSpecific` has no counterpart either, and it is a chart *default* in one place.** Ingress offers `Exact`, `Prefix` and `ImplementationSpecific`; the Gateway API offers `Exact`, `PathPrefix` and `RegularExpression`. The first two map cleanly. `ImplementationSpecific` means "ask the controller", so nothing preserves it — and it is the default in `homeassistant`'s `ingress.hosts`, the one chart keeping upstream's flexible host list. The rule group 3 settled on, and the split is not tidiness: on the path `/` it is rendered as `PathPrefix`, because every controller treats a root `ImplementationSpecific` path as a prefix match and the chart's own default therefore works in Gateway mode unchanged; on **any other path the render aborts**, because in ingress-nginx an `ImplementationSpecific` sub-path is a **regex** and `PathPrefix` is not one, so mapping it silently would change how the path matches while the render stayed green — the #118 class of defect exactly. `RegularExpression` is deliberately not offered as an escape hatch: its support level is Implementation-specific, which is what the first rule above forbids.
|
||||
- **A value derived from `ingress.enabled` is a second thing the sweep has to look for, and it is not visible in the Ingress template.** Gateway mode sets `ingress.enabled: false` *by construction*, so any value computed from it flips — and where it stood for "is there a TLS-terminating proxy in front" or "is this app published at all", the flipped value is wrong while nothing fails. Two finds so far, both fixed by one helper reading `ingress.enabled OR httpRoute.enabled` rather than by a new value:
|
||||
- `sure` (group 1), whose `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL` would have gone `false` with TLS terminated at the Gateway: insecure cookies, no HSTS, `http://` links, green pod. Helper `sure.publishedOverTls`.
|
||||
- `freesailarr` (group 2), whose `freesailarr.sharedIngressApps` was gated on `ingress.enabled` and is read by the `<APP>__SERVER__URLBASE` environment variables as well as by the routing objects. In Gateway mode that list would have come out **empty**, so Radarr, Sonarr and Prowlarr would have been told no base path while their routes served them under `/radarr`, `/sonarr`, `/prowlarr` — a UI whose assets and API calls are linked at the host root, behind a Ready pod. The two guards living in that helper (seerr/mediathekarr only at `/`, no duplicate paths on the shared host) would have gone unenforced in that mode for the same reason. Helper `freesailarr.published`.
|
||||
- `ytdlp-web-player` (group 3), the one this file had listed as still open, and the find with the sharpest consequence. Its `ytdlp-web-player.validate` guard — which refuses to publish the application and the bundled oauth2-proxy on the **same host** — was gated on `ingress.enabled`, so in Gateway mode it went silent. **And that guard is needed more in Gateway mode, not less.** In Ingress mode ignoring it fails loudly: ingress-nginx's admission webhook rejects the second Ingress and the Fleet bundle lands in `ErrApplied`. In Gateway mode **the webhook never fires** — it compares Ingress objects with Ingress objects, and an `HTTPRoute` is a different kind — so nothing rejects anything, both objects stay valid, and one hostname is served over two data paths that are *not* equivalent: the proxy's Ingress goes through **authentication**, the chart's `HTTPRoute` goes straight to the application. Which one a client reaches depends on what DNS resolves to, so it is an authentication bypass that comes and goes with a record, reported by nothing. Helper `ytdlp-web-player.published`, and the Gateway-mode abort carries its own message saying exactly that.
|
||||
|
||||
All three are now closed, and the screen was run over all six charts of group 3 rather than only the suspected one — and over `rss-bridge` as the last chart, where it came back **clean**: outside its Ingress template the only readers of an ingress value are `NOTES.txt`, which knows its own mode, and the `rss-bridge.tlsGroups` helper, which is called only from behind `ingress.enabled`. The application takes no base URL and no hostname in its environment, so there is nothing to compute from the switch and no `published` helper is needed. **A clean result is worth recording too**: the screen's value is that it is run, not that it always finds something, and "this chart needed no helper" is a measurement rather than an omission.
|
||||
- **A subchart does not come along, and that has to be said where somebody will look for it.** `ytdlp-web-player`'s oauth2-proxy dependency has **no Gateway-API support** and this repo does not patch upstream charts (see "Chart dependencies"), so it renders an Ingress or nothing. In the *recommended* configuration — the proxy in the data path, owning the host, the chart's own routing object off — the object that publishes the release is therefore **the subchart's Ingress in both of the chart's modes**, and setting `httpRoute.enabled` there changes nothing about how the host is served. The chart's own route is for the proxy-disabled case or for the application on a host of its own, which is the documented unauthenticated bypass. Stated in the template header, in `values.yaml`, in the schema and in the proxy branch of `NOTES.txt`, because an operator migrating hosts would otherwise set the value and wonder why nothing moved.
|
||||
- **CI**: either the chart's install-test fixture installs in Gateway mode, or it gets a render-only overlay at `ci/httproute/<chart>.yaml` — otherwise `ci/run-httproute-match-test.sh` is green over nothing for it, which that test fails on. An overlay for a multi-app chart has a second job: it must enter **every** shape the template has, or the branch nobody exercised is the one that breaks. `ci/httproute/freesailarr.yaml` gives one app a host of its own so both the shared-host route and the per-app route render.
|
||||
|
||||
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Pilot: `cyberchef`. Group 1 — the charts rendering one Ingress with no behaviour-bearing annotation — followed: `bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`. Group 2 — the charts whose behaviour hangs off an nginx annotation — followed: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` (all four `proxy-body-size`) and `freesailarr` (`rewrite-target`). Group 3 — the special shapes: `homeassistant`, `keycloak`, `bulwark`, `matrix-synapse`, `stalwart`, `ytdlp-web-player`. **`rss-bridge` finishes the sweep**, as the twenty-second and last chart: 22 charts carried an Ingress template and 21 an HTTPRoute template, and the difference was exactly this one. It had been held back as frozen for changes by #51 — a runner defect, not a property of the chart — and the earlier claim that **#114 could not be closed while #51 was open no longer holds**: the maintainer lifted that freeze, so #114 closes with the chart converted while **#51 stays open**, since the runner defect is worked around for this purpose rather than fixed. Its shape: one `HTTPRoute` carrying **all three** of its hostnames, because nothing differs per host (every name serves the same Service at `/`, measured on the live Ingress), so `spec.hostnames` as a list is the whole answer and a second object would repeat one rule. It is also the only chart of the sweep whose Gateway branch has **no loss to document** — its live Ingress carries no `nginx.ingress.kubernetes.io` annotation at all, so the group-3 screen came back empty. Its install-test, red since the move (#51), **went green in the conversion PR** with a pod `Ready` in 22 s and no `Permission denied`; since that PR's rendered defaults are byte-identical to `origin/main`, the chart is ruled out as the cause and the runner is the implicated layer. That green covers Ingress mode, which is what the fixture installs, so the Gateway mode of this chart is render-only like every other chart's.
|
||||
|
||||
**Three of #114's descriptions of the group-3 shapes did not survive contact with the charts**, and the corrected list is worth having here because it is the one somebody will copy from:
|
||||
|
||||
| #114 said | Measured |
|
||||
|---|---|
|
||||
| `keycloak` and `bulwark` have **two** Ingresses each | `keycloak` has two (public and admin hostname) and gets two routes. **`bulwark` has one.** |
|
||||
| `bulwark` is the second `rewrite-target` chart | It has none and cannot have one — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path and its route is one rule at `/`. The grep that produced the claim hit the chart's *refutations* of the idea. |
|
||||
| `matrix-synapse` renders its Ingresses **in a loop over several apps** | That is `freesailarr`. `matrix-synapse` renders **one** Ingress object with **two host rules** — the homeserver at `/` and the delegation nginx at `/.well-known/matrix` — which has to become **two** `HTTPRoute` objects, because `spec.hostnames` applies to the whole object and these two want different paths and different backends. |
|
||||
| `stalwart` renders **per node group** | Its Services and StatefulSets do; the routing object does not and must not. One Ingress targets exactly `ingress.nodeGroup`'s own http Service, and the route keeps both of that template's guards — the management surface is not safe to spread over a group that may not run the management tasks. |
|
||||
|
||||
Three further group-3 specifics: **`keycloak`'s admin Ingress did not hang off `ingress.enabled`** before the sweep (`ingress.admin.enabled` gates it, and that value is not an Ingress switch — it also sets `KC_HOSTNAME_ADMIN`), so `ingress.enabled: false` did not mean "no Ingress" and the object would have gone on claiming the admin host next to the admin route; it is gated on both now, which leaves the default render unchanged and only alters a combination that could not be coherent. `httpRoute.admin.parentRefs` falls back to `httpRoute.parentRefs` exactly as `ingress.admin.className` falls back to `ingress.className`, because **"which data path" is a property of the Gateway in this API, not of the route** — an internal Gateway there is what an internal ingress class is in the other mode. And **`stalwart` is the only chart in the sweep that still renders a `Certificate` in Gateway mode**: its mail certificate covers STARTTLS on 25 and implicit TLS on 465/993, which pass through neither an ingress controller nor a Gateway, so `ingress.domain` stays required there and the object must not be removed as part of a migration — deleting it drops SMTP and IMAP back to a self-signed certificate while every Kubernetes object stays green.
|
||||
|
||||
`garage` was one of #114's named special cases and was done in group 1: it ships `ingress.enabled: false`, so both of its modes are off by default and its route is exercised by a render overlay rather than by the install-test, which keeps its Ingress. `homeassistant` is the same case and the same treatment — its `ingress.enabled` default is `false` too, so group 3's overlay for it **adds** coverage the install-test never had rather than trading one object for another. `paperless-ngx` was the one chart in the repository that still rendered its Ingress **unconditionally**; group 2 gave it an `ingress.enabled` switch defaulting to `true`, because without a second value path there is nothing for the mutual-exclusion abort to name and the `required()` on `clusterIssuer` would have kept demanding a value the Gateway mode does not use.
|
||||
|
||||
**One place where the Gateway API is the better fit, found in group 3**: Matrix delegation. The server name is normally an apex domain that already serves a website, and under ingress-nginx two Ingress objects cannot both claim one hostname — the admission webhook rejects the second — which is why `matrix-synapse` has to carry the `/.well-known/matrix` rule *inside* its own single Ingress object. HTTPRoutes attached to the same listener for the same hostname are **merged** by the implementation, longest match winning, so in Gateway mode that route can sit beside whatever owns the apex without either side knowing about the other.
|
||||
- **Documented exception — freesailarr, and it extends to the Gateway mode**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain` — and therefore **no `httpRoute.hostnames`** either, the only chart without that key. A chart-global list of hostnames could not say which of the ten apps it belonged to, so the hostnames stay where they already are, in `ingress.host` and `<app>.ingress.host`; both are read by both modes and neither is an Ingress switch. In Gateway mode the per-app shape renders one `HTTPRoute` per app (`<release>-<chart>-<app>-httproute`) and the shared shape exactly one (`<release>-<chart>-httproute`) with one rule per app — qBittorrent included, since its prefix-stripping rewrite is a per-rule filter there, and it is the one app contributing **two** rules: the rewrite plus a 301 from the bare, slashless prefix to the slashed form (Gitea #118). Without that redirect the slashless path is stripped to `/`, answered 200, and the page's relative links resolve to the root of the shared host — a different app. **The Ingress mode does not render it and cannot**: nginx offers only a `configuration-snippet` annotation, which is ignored unless the controller runs with `allow-snippet-annotations=true` (off by default since ingress-nginx 1.9, and it means accepting annotation risk level `Critical`), so the chart documents the snippet in `values.yaml` instead of shipping a no-op. It is the one place where the Gateway mode closes a gap rather than merely replacing machinery. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
|
||||
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
|
||||
|
||||
### 5. Security hardening
|
||||
|
||||
@@ -568,7 +378,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
|
||||
@@ -576,11 +385,6 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
- Every Deployment/StatefulSet has a `replicas` value (default `1`, the values path per workload matches its section) that also accepts an explicit `0` for per-workload fine control.
|
||||
- Every chart has a global `scaleDown` flag (default `false`): `scaleDown: true` takes precedence over all `replicas` values, renders every Deployment/StatefulSet with `replicas: 0` and suspends CronJobs. It **stops** the app but does not uninstall it — PVCs, Secrets, Services and Ingress stay in place; setting it back to `false` brings the workloads back up.
|
||||
- Implemented via a per-chart `<chart>.replicas` helper in `_helpers.tpl` (reference in `template-chart`); the helper uses an explicit nil check (`kindIs "invalid"`) because Helm's `default` would swallow an explicit `0`.
|
||||
- **A chart whose default is more than one replica ships a `PodDisruptionBudget`** — `policy/v1`, named `<release>-<chart>-pdb`, `minAvailable` as a value (default `1`), selector matching the workload's `app` label. `keycloak` (Gitea #83) is the first and sets the convention. The reason it is a rule and not a per-chart taste: a node drain is the most common reason a pod moves — it is what a node upgrade does — and without a budget the drain is free to take every replica at once, so the second replica buys nothing against the failure it was added for.
|
||||
- **And it is rendered only while the effective replica count is `>= 2`.** `minAvailable: 1` with exactly one replica forbids every voluntary eviction, so `kubectl drain` blocks forever on a pod nothing may move — and the message names the PDB, not the replica count, which sends the reader to the wrong file. The same applies to `scaleDown: true`, where there are no pods and the object would describe a promise about nothing. So the condition is on the **computed** replica count (the `<chart>.replicas` helper), not on the raw value, and `NOTES.txt` prints which of the two happened rather than leaving it to be inferred from `kubectl get pdb`.
|
||||
- **A chart whose replicas are a property of a *group* gets one PDB per group, with that group's own count** (Gitea #96; `stalwart` is the first, one node group per cluster role). The condition above then applies per group — with `full: 2` and `mx: 1` exactly one object is rendered — and `NOTES.txt` lists the outcome per group rather than once for the release. The name carries the group (`<release>-<chart>-<group>-pdb`), and so does the selector.
|
||||
- **A budget without a distribution rule is half an answer.** The PDB keeps a drain from taking every replica at once; it says nothing about where the replicas were put in the first place. Where a chart's replicas are meant to survive the loss of a node, it also offers a **per-group switch that renders the `topologySpreadConstraints` entry itself, with a selector the chart fills from its own pod labels** — never one the operator writes, because a spread constraint whose selector matches no pod is trivially satisfied and distributes nothing, with a green rollout and no message. `stalwart` sets the convention: `nodes.<group>.spreadAcrossNodes` (default `true`, gated on the same computed `>= 2`), `maxSkew: 1` on `kubernetes.io/hostname`, `whenUnsatisfiable: DoNotSchedule` so the distribution is a promise rather than an effort, and a generic per-group pass-through (`affinity`, `nodeSelector`, `tolerations`, `topologySpreadConstraints`) for everything the switch does not cover. Three traps come with it and all three belong in the chart's `values.yaml`: `nodeAffinityPolicy: Ignore` must be set **explicitly** (its API default is `Honor`, under which a narrowing `nodeSelector` shrinks the set of counted domains until both replicas fit in one, giving skew 0 — co-location with no `Pending`, no event, and a selector that is *correct*, so no CI check can see it); that field is **beta** and is pruned without a word below Kubernetes 1.26, which brings that exact gap back; and the replica ceiling is **a rule, not a number** — as many replicas per group as there are schedulable workers, because `nodeTaintsPolicy` defaults to `Ignore` and control-plane domains therefore hold the minimum at 0 permanently. That ceiling comes from `nodeTaintsPolicy` and is **not** a consequence of setting `nodeAffinityPolicy`; the two are documented separately for that reason. A user entry colliding with the chart's own on `(topologyKey, whenUnsatisfiable)` **aborts the render naming both values**, because the two alternatives are an API error pointing at a list index and a silent merge over `patchMergeKey: topologyKey` that drops one selector and goes green.
|
||||
- **Both selectors are held against the rendered pod labels on every PR** by `ci/run-selector-match-test.sh` — see "Selector-match render test" above. Needed because the shared-helper rule above is a convention, and a convention is the thing a copy breaks.
|
||||
|
||||
### 12. Values that must not lie (#68, #93, #99, #215)
|
||||
|
||||
@@ -659,7 +463,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 +471,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.
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.0.3+up2.8.8
|
||||
appVersion: "2.8.8"
|
||||
|
||||
maintainers:
|
||||
|
||||
+12
-132
@@ -1,14 +1,9 @@
|
||||
{{- $replicas := include "bentopdf.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.bentopdf.replicas) -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
BentoPDF {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
ACCESS
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||
@@ -18,74 +13,24 @@ ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
|
||||
This chart has no secrets and no volumes — the two ingress values above are
|
||||
everything an installation needs.
|
||||
{{- else if $httpRoute.enabled }}
|
||||
|
||||
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- range $hostnames }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
|
||||
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
|
||||
of the routing path — no Gateway, no class, no Certificate. Three things
|
||||
therefore belong to the cluster and not to this chart, and if any of them is
|
||||
missing the hostname above simply does not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed, so
|
||||
they are present — nothing to check.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is
|
||||
visible only in the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is what a working attachment looks like. An empty
|
||||
status means no controller has looked at the route at all.
|
||||
3. The TLS certificate for the hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required —
|
||||
the chart renders no Certificate here, so there is no
|
||||
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
|
||||
on behalf of this release.
|
||||
|
||||
AND TWO THINGS THIS MODE TAKES AWAY FROM YOU, both because the Gateway API
|
||||
has no annotation channel and this chart renders no implementation-specific
|
||||
policy object on purpose:
|
||||
|
||||
* THE X-Robots-Tag HEADER. A publicly reachable instance gets indexed, and
|
||||
its pages carry a canonical link to the vendor's site. In Ingress mode
|
||||
ingress.annotations is the lever (see values.yaml); here there is none in
|
||||
the chart at all — the header has to be added at the Gateway, or the
|
||||
instance has to not be public.
|
||||
* THE REPAIR FOR A PROXY THAT MANGLES RESPONSES. See the two notes below on
|
||||
the cross-origin headers and on .mjs; in this mode neither can be fixed
|
||||
from this chart.
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so this release has no
|
||||
Ingress, no HTTPRoute and no certificate; ingress.domain and
|
||||
ingress.clusterIssuer are not needed. The app is only reachable inside the
|
||||
cluster, as
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate;
|
||||
ingress.domain and ingress.clusterIssuer are not needed. The app is only
|
||||
reachable inside the cluster, as
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
Then browse to http://localhost:8080. To publish it instead, set either
|
||||
ingress.enabled=true with ingress.domain and ingress.clusterIssuer, or
|
||||
httpRoute.enabled=true with ingress.domain and httpRoute.parentRefs.
|
||||
Then browse to http://localhost:8080. Set ingress.enabled=true together with
|
||||
ingress.domain and ingress.clusterIssuer to publish it instead.
|
||||
{{- end }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}bentopdf.replicas: 0{{ end }} — the deployment runs with replicas: 0, so the endpoint
|
||||
above does not answer until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and
|
||||
Ingress stay in place, so the certificate is not re-issued.{{ else if $httpRoute.enabled }} and
|
||||
HTTPRoute stay in place, so the route keeps pointing at a Service with no
|
||||
endpoints — the Gateway answers with an error of its own rather than with
|
||||
nothing.{{ else }} stays in
|
||||
Ingress stay in place, so the certificate is not re-issued.{{ else }} stays in
|
||||
place.{{ end }}
|
||||
{{ end }}
|
||||
A GREEN POD ONLY PROVES DELIVERY
|
||||
@@ -95,40 +40,21 @@ A GREEN POD ONLY PROVES DELIVERY
|
||||
handing out index.html — no more. Whether PDF processing works is a
|
||||
question about the browser, not about this release.
|
||||
|
||||
TWO HEADERS HAVE TO SURVIVE YOUR PROXY
|
||||
TWO HEADERS HAVE TO SURVIVE YOUR INGRESS CONTROLLER
|
||||
The image sends Cross-Origin-Opener-Policy: same-origin and
|
||||
Cross-Origin-Embedder-Policy: credentialless. Without both arriving at the
|
||||
browser there is no SharedArrayBuffer, and the LibreOffice-backed
|
||||
conversions (Word, Excel, PowerPoint) fail while everything else keeps
|
||||
working. Check them through the published URL, not at the pod:
|
||||
working. Check them through the ingress, not at the pod:
|
||||
|
||||
curl -sI https://{{ if .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ else if $httpRoute.enabled }}{{ first $hostnames }}{{ else }}<host>{{ end }}/ | grep -i cross-origin
|
||||
|
||||
The image sends them itself, so they arrive in both modes unless something
|
||||
in the path strips or rewrites them. The difference is the repair: in Ingress
|
||||
mode ingress.annotations can put them back, in Gateway mode that channel does
|
||||
not exist and it is the Gateway's business. Same for the .mjs MIME type (see
|
||||
values.yaml).
|
||||
curl -sI https://{{ if .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ else }}<host>{{ end }}/ | grep -i cross-origin
|
||||
|
||||
ANYONE WHO REACHES THE URL CAN USE IT
|
||||
{{- if $httpRoute.enabled }}
|
||||
BentoPDF has no login, and this chart adds none. That is defensible here —
|
||||
the server takes nothing in, stores nothing and has no session — but a
|
||||
public instance also gets indexed, and its pages carry a canonical link to
|
||||
the vendor's site. In GATEWAY MODE there is no place in this chart for either
|
||||
fix: the Gateway API has no annotation channel, so the X-Robots-Tag snippet
|
||||
documented in values.yaml has NO counterpart here, and the chart renders no
|
||||
implementation-specific policy object on purpose (it would bind the chart to
|
||||
one Gateway implementation). Both have to happen at the Gateway — or the
|
||||
instance must not be publicly reachable.
|
||||
{{- else }}
|
||||
BentoPDF has no login, and this chart adds none. That is defensible here —
|
||||
the server takes nothing in, stores nothing and has no session — but a
|
||||
public instance also gets indexed, and its pages carry a canonical link to
|
||||
the vendor's site. Both are addressed through ingress.annotations; see
|
||||
values.yaml. Note that this escape hatch exists in Ingress mode only:
|
||||
httpRoute.enabled has no annotation channel at all.
|
||||
{{- end }}
|
||||
values.yaml.
|
||||
|
||||
NOTHING TO BACK UP, NOTHING TO MIGRATE
|
||||
No database, no volumes, no uploads: documents never leave the visitor's
|
||||
@@ -153,49 +79,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,90 +0,0 @@
|
||||
{{/*
|
||||
The Gateway-API alternative to bentopdf.ingress.yaml (Gitea #114). Form and
|
||||
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
|
||||
Gateway API".
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, and no implementation-specific policy object - that subset looks
|
||||
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
|
||||
the whole reason the cluster's implementation can be swapped without touching
|
||||
this chart. Class and certificate therefore belong to the PLATFORM, on the
|
||||
Gateway and its listeners.
|
||||
|
||||
WHAT THIS CHART IN PARTICULAR LOSES IN THIS MODE, AND IT IS NOT NOTHING
|
||||
|
||||
ingress.annotations is BentoPDF's only lever for two things it cannot do
|
||||
itself, and the Gateway API has no annotation channel at all:
|
||||
|
||||
* the X-Robots-Tag header that keeps a public instance out of search
|
||||
indexes (ROBOTS_NOINDEX cannot run on a read-only root filesystem, and
|
||||
every page carries a canonical link to the vendor's domain), and
|
||||
* the repair for controllers that override the MIME type of .mjs files or
|
||||
strip Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy.
|
||||
|
||||
None of that is rebuilt here - a policy object would bind the chart to one
|
||||
implementation. NOTES.txt says so in this mode instead of leaving it to be
|
||||
discovered on a search-results page. The two cross-origin headers are sent by
|
||||
the image itself and still arrive unless something strips them, so they are a
|
||||
thing to CHECK (see NOTES.txt) rather than a thing to configure.
|
||||
|
||||
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
|
||||
in this mode - and it is not demanded either: its only required() sits inside
|
||||
bentopdf.ingress.yaml, behind ingress.enabled.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block this chart dereferences (#99): an override
|
||||
written without children - "httpRoute:" with the keys below it deleted - is
|
||||
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
|
||||
pointer. An empty block means "chart defaults apply", and all three defaults
|
||||
are empty or false, so the whole block collapses to "no HTTPRoute".
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
|
||||
even though no Ingress is rendered in this mode - the value names the public
|
||||
hostname of this release, which both modes need; it is not an Ingress switch.
|
||||
|
||||
Checked for THIS chart rather than copied: nothing outside bentopdf.ingress.yaml
|
||||
and this file reads ingress.domain - BentoPDF is a static bundle and is handed
|
||||
no base URL - so with both routing modes off, domain is genuinely free. In
|
||||
Gateway mode this required() is the one that fires.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
|
||||
# backend Service - BentoPDF is served at the root and has no second path.
|
||||
# The port is the Service's port NUMBER and not its name ("http"):
|
||||
# backendRefs takes a PortNumber, so unlike the Ingress this reference
|
||||
# cannot be made by name and the 80 below has to track
|
||||
# bentopdf.service.yaml by hand. ci/run-httproute-match-test.sh is what
|
||||
# holds that pair - it fails when a backendRef names a Service, or a port,
|
||||
# the same render does not create.
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port: 80
|
||||
{{- 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 }}
|
||||
@@ -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"
|
||||
},
|
||||
@@ -100,7 +49,6 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. It does not free ingress.domain by itself: the HTTPRoute reads that value too, so domain is only unnecessary while BOTH routing modes are off. clusterIssuer is read by the Ingress alone.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -113,42 +61,13 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead - including the X-Robots-Tag snippet that is this chart's only lever against being indexed.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/parentRef"
|
||||
}
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the hostname has a single source of truth.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
|
||||
@@ -174,30 +174,13 @@ ingress:
|
||||
# read inside the Ingress template - unlike searxng, this chart has no
|
||||
# second reader of ingress.domain: the upstream counterpart BASE_URL is a
|
||||
# build-time argument and empty in the published image).
|
||||
#
|
||||
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
|
||||
# true the render aborts, naming both values. In Gateway mode ingress.domain
|
||||
# is read again - by the HTTPRoute, as its single hostname - so "both modes
|
||||
# off" is what frees it, not "ingress off".
|
||||
enabled: true
|
||||
clusterIssuer: null
|
||||
# The public hostname of this release. Read by the Ingress AND - when
|
||||
# httpRoute.hostnames is empty - by the HTTPRoute, so it stays the single
|
||||
# source of truth for the hostname in both modes.
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets.
|
||||
#
|
||||
# THESE HAVE NO COUNTERPART IN GATEWAY MODE, AND FOR THIS CHART THAT COSTS
|
||||
# SOMETHING CONCRETE. The Gateway API has no annotation channel for
|
||||
# controller behaviour, so both uses documented below - the X-Robots-Tag
|
||||
# header and any repair for a controller that mangles the headers or the
|
||||
# .mjs MIME type - are simply gone when httpRoute.enabled is used instead.
|
||||
# The chart does not rebuild them: a policy object would bind it to one
|
||||
# Gateway implementation. It is then the Gateway's business. NOTES.txt says
|
||||
# so in that mode.
|
||||
#
|
||||
# TWO HEADERS MUST SURVIVE THE PROXY, OR OFFICE CONVERSION BREAKS. The image
|
||||
# sends Cross-Origin-Opener-Policy: same-origin and
|
||||
# Cross-Origin-Embedder-Policy: credentialless. Together they make the page
|
||||
@@ -229,48 +212,6 @@ ingress:
|
||||
# add-headers ConfigMap is the alternative.
|
||||
annotations: {}
|
||||
|
||||
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
|
||||
# an existing release is unaffected; exactly one of ingress.enabled and
|
||||
# httpRoute.enabled may be true.
|
||||
#
|
||||
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
|
||||
# gatewayClassName, no Certificate and no implementation-specific policy
|
||||
# object. Class and certificate belong to the Gateway and therefore to the
|
||||
# platform, which is what keeps this chart independent of whether the cluster
|
||||
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
|
||||
#
|
||||
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
|
||||
# cannot detect them (.Capabilities.APIVersions is unreliable at template
|
||||
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
|
||||
#
|
||||
# WHAT THIS MODE COSTS HERE: the whole ingress.annotations escape hatch above,
|
||||
# including the X-Robots-Tag header - a public instance in Gateway mode gets
|
||||
# indexed unless the Gateway adds the header. The two cross-origin headers are
|
||||
# a different case: the image sends them itself, so they still arrive in this
|
||||
# mode unless something in the path strips them (NOTES.txt has the curl).
|
||||
httpRoute:
|
||||
enabled: false
|
||||
# The Gateway(s) this route attaches to - a platform object, usually in
|
||||
# another namespace, which must allow this namespace in its listener's
|
||||
# allowedRoutes. One entry per parent, the fields are the Gateway API's
|
||||
# ParentReference (name, and optionally namespace, sectionName, port, group,
|
||||
# kind), passed through unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
# Empty means [ingress.domain], so the hostname has one source of truth. Set
|
||||
# explicitly only to publish under a different or an additional name - which
|
||||
# is how a migration from the Ingress runs: the HTTPRoute takes a second
|
||||
# hostname while the Ingress keeps ingress.domain.
|
||||
hostnames: []
|
||||
|
||||
# WHAT THIS CHART CANNOT DO
|
||||
#
|
||||
# Almost everything else about BentoPDF is a BUILD-TIME argument and therefore
|
||||
@@ -299,79 +240,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,23 +0,0 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -1,36 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: bulwark
|
||||
description: A Helm chart to deploy Bulwark Webmail, a JMAP webmail client (mail, calendar, contacts, files) for a Stalwart mail server
|
||||
icon: https://avatars.githubusercontent.com/u/266882946
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag of ghcr.io/bulwarkmail/webmail.
|
||||
#
|
||||
# THE SUFFIX-FREE FORM, AND THAT IS A CHOICE RATHER THAN THE ONLY OPTION. This
|
||||
# registry publishes every release TWICE - 1.12.0 and v1.12.0 are the same image
|
||||
# under two names (counted 2026-10-05 against the ghcr.io tags API: 315 tags, and
|
||||
# both families run the whole way back to 1.3.9). So unlike stalwart, garage,
|
||||
# matrix-synapse and calibre-web-automated, where the "v" IS the tag and
|
||||
# therefore part of appVersion and of the "+up" suffix, here it is optional and
|
||||
# this chart pins the form without it. Do not "normalize" it to v1.12.0 later:
|
||||
# that would rewrite appVersion and the chart version for an identical image.
|
||||
#
|
||||
# The tag census, Renovate's handling of each shape and the three dry-run
|
||||
# fixtures that prove it are in renovate.json5's bulwark block rather than
|
||||
# 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
|
||||
appVersion: "1.12.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
|
||||
# NO DEPENDENCIES, and nothing here wants one. Bulwark is a client: it holds no
|
||||
# mail, has no database, no cache and no broker. The mail server it talks to is
|
||||
# the `stalwart` chart (Gitea #65), a SEPARATE RELEASE reached over the network
|
||||
# by the URL in bulwark.jmapServerUrl - not a subchart, because the two are
|
||||
# installed, scaled, upgraded and backed up independently and a JMAP endpoint is
|
||||
# an address rather than a bundled component. README, "Chart dependencies", for
|
||||
# the bar a dependency has to clear.
|
||||
@@ -1,382 +0,0 @@
|
||||
{{- /*
|
||||
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
|
||||
workflow or script under ci/ reads it, so every claim below carries
|
||||
review-only assurance - it is checked by being read, not by a stage. A
|
||||
rendering ERROR here does fail `helm template`, which is all the pipeline can
|
||||
see of it.
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
{{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
{{- $url := include "bulwark.publicUrl" . -}}
|
||||
Bulwark Webmail {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{ $replicas }} replica(s). This is a CLIENT: it holds no mail and has no database.
|
||||
The messages and the accounts belong to the Stalwart server at
|
||||
{{ $settings.jmapServerUrl }}
|
||||
If that server is away, this release is still Ready and still green - see
|
||||
"WHAT THE PROBES DO NOT TELL YOU" below.
|
||||
|
||||
ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
|
||||
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
|
||||
{{ else }} Web: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
|
||||
both false) - in-cluster only
|
||||
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }}
|
||||
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
|
||||
{{ else }} Admin: /admin/login on the in-cluster address above. With nothing published, reach it with
|
||||
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }}
|
||||
and open http://localhost:3000/admin/login
|
||||
{{ end }}{{ if or .Values.ingress.enabled $httpRoute.enabled }}
|
||||
Bulwark is served at the ROOT of that host and cannot be moved under a path:
|
||||
NEXT_PUBLIC_BASE_PATH is compiled into the image.{{ if $httpRoute.enabled }} That is also why the
|
||||
HTTPRoute carries no URLRewrite filter - there is no prefix to strip.{{ end }}
|
||||
{{ end }}{{ if $httpRoute.enabled }}
|
||||
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
|
||||
|
||||
This release renders ONE HTTPRoute and NOTHING else of the routing path - no
|
||||
Gateway, no class, no Certificate, no policy object.
|
||||
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
|
||||
|
||||
Three things therefore belong to the cluster, and if any is missing the
|
||||
hostname above simply does not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed, so
|
||||
they are present - nothing to check.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
|
||||
in the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions - "Accepted: True" and
|
||||
"ResolvedRefs: True" is what a working attachment looks like. An empty
|
||||
status means no controller has looked at the route at all.
|
||||
3. The TLS certificate for the hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required: the
|
||||
chart renders no Certificate here, so there is no
|
||||
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
|
||||
for this release.
|
||||
|
||||
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
|
||||
|
||||
In Ingress mode an operator sets nginx.ingress.kubernetes.io/proxy-body-size
|
||||
through ingress.annotations - this installation runs with 25m - because
|
||||
nginx's default of 1m rejects almost every attachment. IN THIS MODE THAT IS
|
||||
GONE: the Gateway API has no body-size field, it is implementation policy
|
||||
rather than routing, and this chart renders no policy object on purpose (it
|
||||
would bind the chart to one Gateway implementation).
|
||||
|
||||
So the limit in force is THE GATEWAY'S, whatever it happens to be, and no
|
||||
value of this chart has any say in it. The failure mode on a webmail client
|
||||
is worth picturing before it happens: a user has written the mail, attached
|
||||
the file and pressed send, and the 413 comes back FROM THE PROXY. It reads
|
||||
like Bulwark being broken, nothing mentions a limit, and the draft is the
|
||||
thing at risk.
|
||||
|
||||
Set the equivalent next to the Gateway, in your implementation's own object,
|
||||
BEFORE you point DNS at it - and size it to what the MAIL SERVER accepts
|
||||
(the Stalwart release above), not to what looks generous here. A webmail
|
||||
client that accepts a 40 MB attachment the mail server will refuse has only
|
||||
moved the failure later.
|
||||
|
||||
ingress.annotations is inert in this mode in general: the Gateway API has no
|
||||
annotation channel for controller behaviour.
|
||||
{{ end }}
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
The operator materializes one Secret key per item FIELD. A missing or misnamed
|
||||
field here does NOT stop the pod - the two file-form secrets simply read as
|
||||
absent - so the symptom is a feature that is quietly off rather than an error.
|
||||
|
||||
{{ .Values.secrets.appSecretRef | default "<secrets.appSecretRef NOT SET>" }}
|
||||
session-secret openssl rand -base64 32
|
||||
MANDATORY. Persistent sessions and the encryption of the
|
||||
synchronized settings both hang on it, and the second
|
||||
fails SILENTLY without it: settings sync is computed as
|
||||
"enabled AND a secret exists", so with no secret it is
|
||||
simply off and nothing says so.
|
||||
admin-password the admin dashboard's initial password. CONSUMED ONCE -
|
||||
see step 5 below.
|
||||
{{ if $settings.oauth.enabled }}
|
||||
{{ .Values.secrets.oauthSecretRef | default "<secrets.oauthSecretRef NOT SET>" }}
|
||||
oauth-client-secret the confidential OIDC client's secret, as issued by
|
||||
the identity provider when the client was registered.
|
||||
{{ else }}
|
||||
(no OAuth item - bulwark.oauth.enabled is false, so sign-in is by password)
|
||||
{{ end }}
|
||||
The file-form secret{{ if $settings.oauth.enabled }}s are{{ else }} is{{ end }} mounted as a DIRECTORY, not with subPath, and
|
||||
re-read on every use - so rotating {{ if $settings.oauth.enabled }}either field{{ else }}that field{{ end }} in the vault takes effect
|
||||
WITHOUT a restart. admin-password is the exception and no restart helps it.
|
||||
|
||||
{{ if $settings.oauth.enabled }}
|
||||
BEFORE YOU INSTALL - THE OIDC CLIENT HAS TO EXIST FIRST
|
||||
{{ if $settings.oauth.only }}
|
||||
THIS IS NOT ADVICE AND IT IS NOT A FOLLOW-UP STEP. bulwark.oauth.only is ON,
|
||||
which means the password form is hidden, which means that WITHOUT A WORKING
|
||||
OIDC CLIENT THE FIRST SIGN-IN IS IMPOSSIBLE. There is no fallback for users.
|
||||
{{ end }}
|
||||
Register a CONFIDENTIAL client with the provider at
|
||||
{{ $settings.oauth.issuerUrl }}
|
||||
client id: {{ $settings.oauth.clientId }}
|
||||
secret: into the vault item's oauth-client-secret field
|
||||
|
||||
THE REDIRECT URI CARRIES THE UI LANGUAGE, AND THAT IS THE ONE THING THAT WILL
|
||||
COST YOU THE FIRST SIGN-IN IF YOU REGISTER A SINGLE URI. Bulwark builds it as
|
||||
{{ $url }}/<locale>/auth/callback
|
||||
where <locale> is the language the VISITOR'S BROWSER resolved to - so a German
|
||||
browser sends /de/auth/callback and an English one /en/auth/callback, from the
|
||||
same deployment. The language is not in the visible URL (the locale prefix is
|
||||
"never" in this image), which is exactly why this is surprising: the address
|
||||
bar shows no language and the redirect_uri contains one.
|
||||
|
||||
Register ONE PER LANGUAGE YOU EXPECT, or a wildcard if the provider allows it.
|
||||
On Keycloak the wildcard is the practical answer:
|
||||
{{ $url }}/*
|
||||
Otherwise the 27 locales Bulwark supports are:
|
||||
ar ca cs da de en es fa fr he hu it ja ko lv mn nb nl pl pt ro ru sk tr uk
|
||||
zh zh-TW
|
||||
|
||||
If a sign-in fails with a redirect_uri mismatch, read the URI out of the
|
||||
failed request rather than guessing which language it was - that is the value
|
||||
the provider compares byte for byte.
|
||||
|
||||
POST-LOGOUT, IF YOU USE IT: bulwark.oauth.endSession is {{ $settings.oauth.endSession }}, and a
|
||||
post-logout redirect URI is only sent when OAUTH_POST_LOGOUT_REDIRECT_URI is
|
||||
set (this chart does not render it). If you add it through bulwark.env, it has
|
||||
to be registered with the provider too.
|
||||
|
||||
The issuer must be reachable BY THE POD, and the endpoints it advertises must
|
||||
share the issuer's origin or be publicly routable - Bulwark rejects a
|
||||
discovered endpoint on a private address from a different origin. A
|
||||
split-horizon DNS setup needs OAUTH_ALLOW_PRIVATE_ENDPOINTS through
|
||||
bulwark.env; this chart does not render it.
|
||||
{{ end }}
|
||||
FIRST INSTALL
|
||||
|
||||
1. kubectl -n {{ .Release.Namespace }} rollout status statefulset/{{ $sfs }}
|
||||
Ready means the HTTP server is accepting. It does NOT mean the mail server
|
||||
is reachable - nothing in this chart checks that.
|
||||
|
||||
2. Confirm the setup wizard really is OFF. It is, as long as
|
||||
bulwark.jmapServerUrl is set, and the chart refuses to render without it -
|
||||
but this is the one thing worth seeing rather than trusting, because a
|
||||
wizard that ran once changes the rules for good (step 4):
|
||||
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ $sfs }} | grep -i "SETUP REQUIRED"
|
||||
Nothing found is the correct result. A "SETUP REQUIRED" banner with a token
|
||||
means Bulwark is in bootstrap mode and something is wrong with the value.
|
||||
|
||||
3. Sign in{{ if $settings.oauth.only }} through the identity provider{{ end }} at {{ $url }}
|
||||
|
||||
4. READ THIS BEFORE YOU OPEN THE ADMIN DASHBOARD. The dashboard writes
|
||||
OVERRIDES into config.json on the volume, and an override BEATS this chart
|
||||
for that key - for good, and silently.
|
||||
|
||||
Priority is: admin override > environment variable > default.
|
||||
|
||||
So if you save a server URL in the dashboard, changing bulwark.jmapServerUrl
|
||||
and running helm upgrade afterwards does nothing and reports success. The
|
||||
same is true of every other key the dashboard can write. What the chart
|
||||
sets is a DEFAULT, not a setting.
|
||||
|
||||
To see which keys are currently overridden:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- cat /app/data/admin/config.json
|
||||
An empty object, or a file that does not exist, means this chart is in
|
||||
force.
|
||||
|
||||
5. THE ADMIN PASSWORD IS CONSUMED EXACTLY ONCE. On the first start it is
|
||||
hashed into /app/data/admin/admin.json and the environment variable is
|
||||
never read again. Rotating the vault field afterwards has NO EFFECT -
|
||||
changing the password goes through the dashboard, or by deleting admin.json
|
||||
from the volume and restarting. Know that before you treat the vault field
|
||||
as the source of truth.
|
||||
|
||||
IF NOBODY CAN SIGN IN - THE WAY BACK, AND IT IS NOT A helm upgrade
|
||||
|
||||
Read this now, not when it happens, because the obvious repair does not work.
|
||||
|
||||
The situation: the identity provider is down, or its configuration is wrong,
|
||||
and bulwark.oauth.only hides the password form. No user can get in.
|
||||
|
||||
WHAT DOES NOT WORK RELIABLY: helm upgrade with bulwark.oauth.only=false.
|
||||
oauthOnly is an ordinary config key, and every part of the app reads it
|
||||
through the priority chain above - not from the environment. If the admin
|
||||
dashboard's OAuth setup has ever been used, "oauthOnly": true is sitting in
|
||||
config.json on the volume, and from then on the chart's value is ignored. The
|
||||
upgrade succeeds and changes nothing.
|
||||
|
||||
WHAT DOES WORK, and it does not depend on the identity provider:
|
||||
|
||||
a) Open {{ $url }}/admin/login and sign in with the admin-password field
|
||||
from the vault item. This door has its own password and is not behind
|
||||
OIDC.{{ if eq $settings.stalwartAdminAccess "auto" }} (bulwark.stalwartAdminAccess is "auto", so a Stalwart
|
||||
admin also gets in here without that password.){{ end }}
|
||||
b) In the dashboard, REVERT the override - the dashboard's own "remove
|
||||
override" on the OAuth settings. That is what puts the chart's value back
|
||||
in charge.
|
||||
|
||||
IF THE DASHBOARD ITSELF IS UNREACHABLE, the last resort is the volume:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- cat /app/data/admin/config.json
|
||||
# remove the "oauthOnly" key (and any other override in the way), then
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
|
||||
Deleting admin.json in the same directory resets the admin password to
|
||||
whatever the vault field currently holds, on the next start.
|
||||
|
||||
WHAT THE PROBES DO NOT TELL YOU
|
||||
|
||||
All three probes are a plain HTTP GET on /api/health, and that endpoint returns
|
||||
200 whatever state the application is in - for an unauthenticated caller the
|
||||
body even says "healthy" unconditionally. So a green pod here means: the
|
||||
process is alive, HTTP is being accepted, the event loop is free, and Next.js
|
||||
can route.
|
||||
|
||||
IT SAYS NOTHING ABOUT THE MAIL SERVER. With Stalwart completely gone, this
|
||||
release is Ready, green, and serves a web app that cannot fetch a single
|
||||
message. Monitor the stalwart release for that - there is no endpoint here
|
||||
that could cover it, and the one that would (/api/setup/test-jmap) sits behind
|
||||
the setup token this chart disables.
|
||||
|
||||
It also says nothing about the reported state: /api/health reports "degraded"
|
||||
on high V8 heap usage, but only to an authenticated caller asking for details,
|
||||
and always with HTTP 200. A memory limit set too low therefore produces a pod
|
||||
that is permanently degraded and permanently Ready.
|
||||
|
||||
BACKUPS - THE VOLUME ALONE IS NOT ONE, AND IT MAY NEED PROTECTING ITSELF
|
||||
|
||||
Two statements, both of them about {{ .Release.Name }}-{{ .Chart.Name }}-pvc:
|
||||
|
||||
1. A BACKUP OF THE VOLUME ALONE IS NOT A BACKUP.{{ if $settings.settingsSync }} The synchronized user
|
||||
settings on it are encrypted with session-secret.{{ else }} The user settings are not
|
||||
synchronized in this release, but persistent sessions still are.{{ end }} Lose the vault
|
||||
item and that data is unreadable out of a perfectly intact volume. Back up
|
||||
the 1Password item together with the volume, or the restore is a directory
|
||||
of ciphertext.
|
||||
|
||||
2. A BACKUP OF THE VOLUME MAY CONTAIN A SECRET IN CLEARTEXT. If anybody has
|
||||
used the dashboard's OAuth setup, the OIDC client secret is written as
|
||||
PLAINTEXT into config.json on this volume. So the backup may need the same
|
||||
protection the vault gets. Check with:
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $sfs }}-0 -- grep -l oauthClientSecret /app/data/admin/config.json
|
||||
|
||||
TRAPS WORTH KNOWING BEFORE YOU NEED THEM
|
||||
|
||||
- WEB PUSH GOES THROUGH THE VENDOR'S RELAY until somebody changes it, and the
|
||||
change is NOT a chart value. The built-in default is
|
||||
https://notifications.relay.bulwarkmail.org and it is compiled into the
|
||||
image. A self-hosted relay CAN be made the effective default and even
|
||||
mandatory for all users - through the settings policy in the admin
|
||||
dashboard, because that setting has no environment variable at all. So this
|
||||
is a choice you have, not a limitation you are stuck with; it just lives in
|
||||
the dashboard.
|
||||
|
||||
- THERE IS NO ConfigMap IN THIS CHART, so no checksum/config annotation, and
|
||||
that is not an omission: there is no rendered config file whose change could
|
||||
fail to restart anything. Repointing secrets.* at a different vault item
|
||||
DOES roll the pod (checksum/secret-ref). Changing a value INSIDE an item
|
||||
does not - but for the two file-form secrets it does not need to, because
|
||||
they are re-read on every use.
|
||||
{{ if $settings.adminConfigReadOnly }}
|
||||
- bulwark.adminConfigReadOnly IS ON. The chart is authoritative and no
|
||||
override can appear - and three things are frozen with it: the settings
|
||||
policy (so Web Push cannot be moved to your own relay any more), any
|
||||
override that was ALREADY on the volume (it cannot be removed either), and -
|
||||
if this release has never started with it off - the admin password, which
|
||||
means the admin dashboard never comes up and its login answers HTTP 500
|
||||
while this pod stays Ready.
|
||||
If you have just enabled this on a fresh install, turn it off, let the pod
|
||||
start once so admin.json is written, and only then turn it back on.
|
||||
{{ else }}
|
||||
- bulwark.adminConfigReadOnly is OFF, which is the default and keeps the
|
||||
dashboard working. The cost is the precedence trap of step 4: the volume can
|
||||
override this chart at any time. Turning it on makes the chart authoritative
|
||||
but freezes the settings policy, freezes any override already present, and
|
||||
must never be the state of a FIRST install - see values.yaml for the
|
||||
sequence.
|
||||
{{ end }}
|
||||
- THE SETTINGS POLICY, THE THEMES AND THE BRANDING ARE NOT IN THIS CHART.
|
||||
They live in policy.json on the volume and are set in the dashboard. This
|
||||
chart neither renders nor backs them up beyond the volume itself.
|
||||
|
||||
WHAT THIS CHART DOES NOT RENDER
|
||||
|
||||
- No ConfigMap (see above) and no NetworkPolicy. The pod needs egress to the
|
||||
JMAP server and to the OIDC issuer; a policy added later has to allow both,
|
||||
or sign-in breaks in a way that looks like a provider outage.
|
||||
- No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid
|
||||
every voluntary eviction and make `kubectl drain` hang forever on a pod
|
||||
nothing may move (README, guideline 11).
|
||||
- No sub-path Ingress and no sub-path route, and no value that could add
|
||||
one - the base path is compiled into the image. Hence also no URLRewrite
|
||||
filter in Gateway mode.
|
||||
- No proxy-body-size annotation. Attachment uploads go through the Ingress and
|
||||
nginx's default of 1m rejects larger ones with a 413 that reads like an
|
||||
application fault; the right number is the mail server's limit, which this
|
||||
chart does not know. Set it in ingress.annotations{{ if $httpRoute.enabled }} - except that in GATEWAY
|
||||
MODE there is no annotation to set and no counterpart at all, which is the
|
||||
section above{{ end }}.
|
||||
- No Gateway, no GatewayClass and no listener, in either mode. Those are
|
||||
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,481 +0,0 @@
|
||||
{{/*
|
||||
Render the body of a container "resources:" block from a values dict of the form
|
||||
|
||||
resources:
|
||||
limitFactor: 3 # optional, default 3
|
||||
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||
limits: { ... } # optional, every entry optional
|
||||
|
||||
Rules (variant B, see issue #19):
|
||||
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||
to the Kubernetes key "ephemeral-storage").
|
||||
- an explicitly set limit always wins.
|
||||
- a missing memory / ephemeral-storage limit is computed as
|
||||
limitFactor x the corresponding request.
|
||||
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||
this chart takes none. The reference implementation is
|
||||
template-chart/templates/_helpers.tpl.
|
||||
*/}}
|
||||
{{- define "bulwark.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
{{- $factor := 3.0 -}}
|
||||
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||
{{- $factor = float64 $resources.limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict -}}
|
||||
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $requests (include "bulwark.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "bulwark.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "bulwark.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $out := dict -}}
|
||||
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Normalize a resource name from values to its Kubernetes key.
|
||||
*/}}
|
||||
{{- define "bulwark.resourceKey" -}}
|
||||
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||
*/}}
|
||||
{{- define "bulwark.multiplyQuantity" -}}
|
||||
{{- $quantity := include "bulwark.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $suffix := trimPrefix $number $quantity -}}
|
||||
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $product := mulf (float64 $number) .factor -}}
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s%s" (include "bulwark.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation
|
||||
(issue #157): the null-safe fallback helpers pass values through fromYaml,
|
||||
which parses every number as float64, and Go's "%v" switches float64 to
|
||||
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
|
||||
Kubernetes quantity. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "bulwark.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the StatefulSet.
|
||||
|
||||
Input: dict "scaleDown" <flag|nil> "replicas" <value|nil>.
|
||||
|
||||
- scaleDown: true takes precedence and always yields 0.
|
||||
- otherwise an explicitly set replicas value is used as given, including an
|
||||
explicit 0 - Helm's "default" would swallow 0, hence the kindIs "invalid"
|
||||
nil check.
|
||||
- a missing replicas value defaults to 1.
|
||||
|
||||
A NOTE SPECIFIC TO THIS CHART: the schema caps this at 1, and the reason is the
|
||||
single ReadWriteOnce PVC rather than anything about the application. Two pods
|
||||
cannot both mount it, so a second replica would sit in ContainerCreating
|
||||
forever - and the PVC is not optional here, because /app/data carries the admin
|
||||
configuration, the audit log and the synchronized user settings. An RWX volume
|
||||
would not make it a cluster either: nothing in Bulwark coordinates two
|
||||
processes over that directory.
|
||||
*/}}
|
||||
{{- define "bulwark.replicas" -}}
|
||||
{{- if .scaleDown -}}
|
||||
0
|
||||
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||
{{- .replicas -}}
|
||||
{{- else -}}
|
||||
1
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||
null / empty override falls back to the defaults instead of erasing them
|
||||
(issues #61, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
||||
<string>. Output: YAML of the merged dict (consume it with fromYaml).
|
||||
|
||||
An override key written without children is YAML null, and Helm treats that
|
||||
null as a deletion of the chart's default. This helper defines the opposite
|
||||
semantics - an empty block means "chart defaults apply" - while an explicitly
|
||||
set value still wins, including false and 0.
|
||||
*/}}
|
||||
{{- define "bulwark.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
{{- $given := .given -}}
|
||||
{{- $path := .path | default "value" -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $given) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- $out := deepCopy $defaults -}}
|
||||
{{- range $key, $value := $given -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $default := index $defaults $key -}}
|
||||
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||
{{- $_ := set $out $key (fromYaml (include "bulwark.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||
{{- else if kindIs "map" $default -}}
|
||||
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $key $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pick one sub-block out of a values block that may itself be null.
|
||||
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||
Returns YAML of dict "value" <the sub-block, may be nil>.
|
||||
*/}}
|
||||
{{- define "bulwark.subBlock" -}}
|
||||
{{- $block := .block -}}
|
||||
{{- if kindIs "invalid" $block -}}
|
||||
{{- $block = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $block) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||
{{- end -}}
|
||||
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE EFFECTIVE BULWARK SETTINGS - everything the StatefulSet, the Service and
|
||||
NOTES.txt read out of .Values.bulwark, merged onto this chart's defaults.
|
||||
|
||||
Why the whole settings block goes through the null-safe helper and not only the
|
||||
three usual suspects (guideline 12.2: "the rule is about the shape, not about
|
||||
those three block kinds"): every value below is dereferenced by a template, and
|
||||
an erased one would not abort the render. An erased bulwark.port renders an
|
||||
empty containerPort and an empty Service targetPort - rejected by nothing until
|
||||
the API server sees it; an erased bulwark.oauth.enabled would silently turn OIDC
|
||||
OFF on a release whose whole login path is OIDC, which is the quiet half and the
|
||||
worse one. Satisfactory shipped exactly this shape with half its blocks
|
||||
null-safe and half not (Gitea #16).
|
||||
|
||||
The defaults here MUST stay in sync with values.yaml, which remains the
|
||||
documented, user-facing place for them; this copy is only the fallback.
|
||||
|
||||
Usage: {{ $settings := fromYaml (include "bulwark.settings" .) }}
|
||||
*/}}
|
||||
{{- define "bulwark.settings" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" .Values "key" "bulwark" "path" ".Values"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"replicas" 1
|
||||
"port" 3000
|
||||
"jmapServerUrl" nil
|
||||
"appName" nil
|
||||
"stalwartFeatures" true
|
||||
"stalwartAdminAccess" "auto"
|
||||
"settingsSync" true
|
||||
"adminConfigReadOnly" false
|
||||
"telemetry" false
|
||||
"updateCheck" false
|
||||
"logLevel" "info"
|
||||
"logFormat" "json"
|
||||
"writableCache" (dict
|
||||
"tmpSizeLimit" "256Mi"
|
||||
"nextCacheSizeLimit" "512Mi")
|
||||
"oauth" (dict
|
||||
"enabled" true
|
||||
"only" true
|
||||
"clientId" nil
|
||||
"issuerUrl" nil
|
||||
"scopes" nil
|
||||
"endSession" true) -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE DATA DIRECTORY, defined once because two templates have to agree on it: the
|
||||
StatefulSet mounts the PVC here and NOTES.txt names it.
|
||||
|
||||
/app/data, and it is NOT a value. Bulwark resolves its four directories
|
||||
relative to the process working directory - getConfigDir() falls back to
|
||||
<cwd>/data/admin and getStateDir() to <cwd>/data/admin-state (lib/admin/paths.ts
|
||||
at tag 1.12.0) - and cwd is /app (the image's WORKDIR). A path that disagreed
|
||||
with the mount would put the admin configuration and the audit log into the
|
||||
container's ephemeral layer, which looks like it works until the pod is
|
||||
replaced. The image creates all four subdirectories and chowns them to
|
||||
1001:1001 (Dockerfile: mkdir -p /app/data/{settings,admin,admin-state,telemetry}
|
||||
&& chown -R nextjs:nodejs /app/data) - but that happens in the IMAGE LAYER,
|
||||
which the PVC mount covers, so the subdirectories are created again at runtime
|
||||
by Bulwark itself (ensureConfigDir / ensureStateDir, both mkdir -p). fsGroup is
|
||||
what makes the empty volume writable for uid 1001.
|
||||
*/}}
|
||||
{{- define "bulwark.dataDir" -}}/app/data{{- end -}}
|
||||
|
||||
{{/*
|
||||
WHERE THE TWO FILE-FORM SECRETS ARE MOUNTED, and the one property of this mount
|
||||
that must not be "tidied up": it is a WHOLE-DIRECTORY mount with NO subPath.
|
||||
|
||||
The kubelet never refreshes a subPath mount. Both files are re-read on every
|
||||
use - getSessionSecret() calls readFileEnv on every call
|
||||
(lib/auth/session-secret.ts) and getGlobalClientSecret() does the same on every
|
||||
token exchange (lib/oauth/token-exchange.ts, no cache) - so rotating a field
|
||||
inside the vault item takes effect WITHOUT a pod restart. With a subPath that
|
||||
property is silently lost: the file in the container would keep the old value
|
||||
forever while the vault, the Secret and `helm upgrade` all look correct.
|
||||
*/}}
|
||||
{{- define "bulwark.secretMountPath" -}}/etc/bulwark/secrets{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
The defaults below MUST stay in sync with values.yaml. They reproduce THIS
|
||||
chart's defaults, not the repo standard.
|
||||
*/}}
|
||||
{{- define "bulwark.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" . "key" "pod" "path" "bulwark.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 1001
|
||||
"runAsGroup" 1001
|
||||
"fsGroup" 1001
|
||||
"fsGroupChangePolicy" "Always"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "bulwark.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "bulwark.subBlock" (dict "block" . "key" "container" "path" "bulwark.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" $given "path" "bulwark.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
ALL THREE PROBES ARE AN HTTP GET ON /api/health, AND WHAT THAT DOES AND DOES NOT
|
||||
CARRY IS MEASURED AGAINST THE SOURCE AT TAG 1.12.0 - not read off the path name,
|
||||
which is the mistake #218 and Gitea #83 each cost this repo once.
|
||||
|
||||
FIRST, WHY GET AND NOT HEAD, because the plan for this chart (Gitea #89) said
|
||||
HEAD and HEAD IS NOT AVAILABLE TO A KUBERNETES PROBE AT ALL. The API type is
|
||||
HTTPGetAction and it has no method field; the kubelet's prober issues a GET and
|
||||
there is no value anywhere in a pod spec that changes that. The only way to send
|
||||
a HEAD would be an exec probe shelling out to a HTTP client - and this image
|
||||
contains no curl and no wget (npm and npx are deliberately removed from the
|
||||
runtime layer), so it would mean `node -e` on every probe tick: a new Node
|
||||
process every few seconds against an endpoint whose GET handler is already
|
||||
cheap. That is strictly worse, so the chart uses the GET that Kubernetes
|
||||
actually offers.
|
||||
|
||||
WHAT CHANGES BECAUSE OF THAT: almost nothing, and in the direction that helps.
|
||||
Upstream's HEAD handler returns `new Response(null, { status: 200 })` and
|
||||
computes nothing. The GET handler does gather diagnostics, which makes it very
|
||||
slightly more capable than HEAD rather than less: its catch block answers 503
|
||||
when `process.memoryUsage()` or `v8.getHeapStatistics()` itself throws. Every
|
||||
limitation argued below is unchanged.
|
||||
|
||||
WHAT THE ENDPOINT DOES. The GET success path returns a hard `status: 200`
|
||||
whatever state it computed, and for an UNAUTHENTICATED caller the body is
|
||||
hard-coded to `healthy` as well (`status: detailed ? status : 'healthy'`);
|
||||
`degraded` appears only with `?detailed=true` AND a caller that passes
|
||||
canSeeInstanceDetails. Upstream tests exactly that -
|
||||
lib/__tests__/health-route.test.ts expects HTTP 200 while `payload.status` is
|
||||
`degraded`. A probe is unauthenticated and asks for no details, so it sees 200
|
||||
and "healthy" always.
|
||||
|
||||
SO THE PROBE CANNOT REPORT THE APPLICATION'S STATE - and this is deliberately
|
||||
NOT filed as "a probe that cannot fail", because that would be wrong and would
|
||||
invite somebody to replace it with something worse. What a 200 here really
|
||||
proves, all of it load-bearing: the Node process is alive, the HTTP server is
|
||||
accepting, the event loop is not blocked, and Next.js can route a request. An
|
||||
OOM kill, a crash, a wedged event loop and a container that never finished
|
||||
booting all fail this probe. That is more than #218's endpoint carried and it is
|
||||
exactly what a liveness and a startup probe are for.
|
||||
|
||||
WHAT IT DOES NOT CARRY, and no probe in this chart can: anything about
|
||||
STALWART. Bulwark holds no mail and this endpoint does not ask the JMAP server
|
||||
anything, so with the mail server gone /api/health is green and the pod stays
|
||||
Ready. There is no better endpoint - /api/setup/test-jmap would really test the
|
||||
connection, but it lives behind the bootstrap setup token and is unreachable in
|
||||
this chart (the wizard is disabled by jmapServerUrl). The limit is written at
|
||||
the readiness value in values.yaml because readiness is the probe whose promise
|
||||
it weakens.
|
||||
*/}}
|
||||
{{- define "bulwark.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "bulwark.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
STARTUP: the same endpoint, and it gates the other two - which is why neither of
|
||||
them carries an initialDelaySeconds.
|
||||
|
||||
BUDGET = periodSeconds x failureThreshold = 5 x 24 = 120s. What it has to cover
|
||||
is not Bulwark's own boot, which is a Next.js standalone server starting in
|
||||
about a second: it is attaching and mounting the network volume, and the
|
||||
instrumentation hook that runs BEFORE the first request is served -
|
||||
instrumentation.node.ts migrates the legacy admin layout, loads config.json and
|
||||
policy.json off the volume, and on a first install hashes ADMIN_PASSWORD with
|
||||
scrypt and writes admin.json. The scrypt hash is the one deliberately slow step
|
||||
in there.
|
||||
*/}}
|
||||
{{- define "bulwark.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/api/health" "port" "http" "scheme" "HTTP")
|
||||
"periodSeconds" 5
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 24 -}}
|
||||
{{- include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective resources block, fed into the resources helper above so that an
|
||||
erased "resources:" still yields the chart's requests and computed limits.
|
||||
*/}}
|
||||
{{- define "bulwark.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi") -}}
|
||||
{{- $merged := fromYaml (include "bulwark.defaultedDict" (dict "defaults" $defaults "given" . "path" "bulwark.resources")) -}}
|
||||
{{- include "bulwark.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The additive env block, rendered through tpl with the value, secretKeyRef and
|
||||
configMapKeyRef forms (guideline 10).
|
||||
|
||||
ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does
|
||||
not replace it. At install time both reach the pod and the later one wins; on an
|
||||
EXISTING release the pair collapses to the chart's own value, because Kubernetes
|
||||
merges env by name - and helm upgrade still reports success. So do not use this
|
||||
block to override JMAP_SERVER_URL, OAUTH_ONLY or SESSION_SECRET_FILE; change the
|
||||
values that produce them.
|
||||
|
||||
AND THERE IS A SECOND, SHARPER REASON NOT TO USE IT FOR BULWARK'S OWN SETTINGS,
|
||||
specific to this application: an environment variable is only the MIDDLE layer
|
||||
of three. configManager.get resolves "admin override > env var > default"
|
||||
(lib/admin/config-manager.ts), so any key the admin dashboard has written into
|
||||
config.json on the PVC beats whatever arrives here - silently. An entry added
|
||||
through this block is therefore not a guarantee, it is a default that the volume
|
||||
may already be overriding. values.yaml, under bulwark.adminConfigReadOnly, has
|
||||
the whole precedence story.
|
||||
*/}}
|
||||
{{- define "bulwark.env" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- range $value := .env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "3000" 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".
|
||||
value: {{ tpl $value.value $root | quote }}
|
||||
{{- else if $value.valueFrom }}
|
||||
{{- if $value.valueFrom.secretKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
|
||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The public base URL, derived from ingress.domain. NOTES.txt prints it and builds
|
||||
the OIDC redirect URI from it, which is why it is a helper rather than several
|
||||
copies of the same printf.
|
||||
|
||||
DELIBERATELY NO required() HERE, and that is the opposite of what it first looks
|
||||
like it should have. The only caller is NOTES.txt, and `helm template` does not
|
||||
render NOTES.txt (README, guideline 12.2) - so a required() in this helper would
|
||||
be a guard that cannot fire in lint, in the render test or in CI, and would fire
|
||||
for the first time during a real `helm install`. A guard that only runs in
|
||||
production is worse than no guard.
|
||||
|
||||
The honest position for THIS chart, checked in both directions as guideline 12.1
|
||||
asks, AND CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): it used to say
|
||||
that nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
|
||||
That was true and is no longer - bulwark.httproute.yaml reads it as the route's
|
||||
hostname, with a required() of its own. What still holds is the half that made
|
||||
the claim worth making: Bulwark derives its own public URL from the request
|
||||
origin and takes no base-URL variable, so with BOTH modes off the release is
|
||||
complete without a domain - the calibre-web-automated case, where the gate
|
||||
frees both values. The enforcement sits in whichever routing template is
|
||||
active; this helper still renders a visible placeholder instead of failing,
|
||||
because `helm template` does not render NOTES.txt.
|
||||
|
||||
IN GATEWAY MODE THE PUBLIC URL IS THE ROUTE'S HOSTNAME, not ingress.domain, and
|
||||
the two differ exactly when httpRoute.hostnames is set explicitly - which is
|
||||
how a migration off the Ingress runs. Printing ingress.domain there would hand
|
||||
the operator an OIDC redirect URI for the host being migrated AWAY from, and
|
||||
that failure surfaces as a rejected login at the identity provider rather than
|
||||
as anything about this chart. So the helper follows the active mode.
|
||||
*/}}
|
||||
{{- define "bulwark.publicUrl" -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $host := .Values.ingress.domain -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $host = (first ($httpRoute.hostnames | default list)) | default .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
{{- if $host -}}
|
||||
https://{{ $host }}
|
||||
{{- else if $httpRoute.enabled -}}
|
||||
https://<neither httpRoute.hostnames nor ingress.domain SET>
|
||||
{{- else -}}
|
||||
https://<ingress.domain NOT SET>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -1,115 +0,0 @@
|
||||
{{/*
|
||||
HTTPRoute - the Gateway-API alternative to bulwark.ingress.yaml (Gitea #114).
|
||||
|
||||
ONE ROUTE, ONE RULE, AT THE ROOT - and the reason is the same one that makes
|
||||
the Ingress rule permanently "/": NEXT_PUBLIC_BASE_PATH is compiled into
|
||||
Bulwark's bundle, so the application cannot be served under a sub-path at all.
|
||||
Which is also why THIS CHART RENDERS NO URLRewrite FILTER and needs none. That
|
||||
differs from what #114's measured table and the header of
|
||||
ci/run-httproute-match-test.sh both expected of this chart, so it is worth
|
||||
writing down where it will be read:
|
||||
|
||||
- This chart has ONE Ingress object, not two (keycloak has two).
|
||||
- This chart has NO rewrite-target annotation and CANNOT have one. Every
|
||||
occurrence of the string "rewrite-target" in this chart - values.yaml, the
|
||||
schema, the Ingress template - is an explanation of why it does not help:
|
||||
the bundle's own asset and API URLs are built for the root, so stripping a
|
||||
prefix at the proxy yields a page that loads and then fetches everything
|
||||
from the wrong place. A `git grep rewrite-target bulwark/` finds those
|
||||
refutations and counts them as occurrences, which is how the expectation
|
||||
arose.
|
||||
- Consequently the #118 prefix/redirect assurance in
|
||||
ci/run-httproute-match-test.sh does not apply here: with no URLRewrite on a
|
||||
prefix other than "/", its precondition is never met. The bare-prefix
|
||||
redirect that freesailarr needs has nothing to redirect here.
|
||||
|
||||
WHAT THIS RENDERS, AND WHAT IT MUST NOT
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, no implementation-specific policy object - the subset that looks
|
||||
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
|
||||
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
|
||||
listeners, in cluster-config.
|
||||
|
||||
WHAT THIS MODE DROPS FOR THIS CHART, MEASURED ON THE LIVE OBJECT
|
||||
|
||||
On 2026-10-12 the live Ingress (jk-cluster, namespace bulwark,
|
||||
webmail.bekemeyer-klein.de) carried
|
||||
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 25m
|
||||
|
||||
The CHART does not set it - values.yaml recommends exactly that value and
|
||||
explains why the chart cannot pick it (the right number is the mail server's,
|
||||
which this chart does not know), so the 25m is the operator's, supplied through
|
||||
ingress.annotations. It is therefore invisible to anyone reading this
|
||||
repository alone, and it is the single annotation this installation depends on.
|
||||
|
||||
In this mode it is gone. The Gateway API has no body-size counterpart: it is
|
||||
implementation policy, not routing, and decision 2 of #114 says such a limit is
|
||||
DOCUMENTED rather than rebuilt. So the Gateway branch of NOTES.txt names it in
|
||||
this chart's own terms - an attachment above the Gateway's default is rejected
|
||||
before Bulwark sees a byte, with a 413 from the proxy that reads like an
|
||||
application fault, on the one application whose whole purpose is sending files
|
||||
to other people.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block a template here dereferences (#99): an
|
||||
override written without children - "httpRoute:" with its keys deleted - is
|
||||
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Bulwark it also breaks the login: the OIDC redirect URI registered with the identity provider names ONE origin, and a session that starts on one data path and returns on the other fails on the state cookie. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain) with that second origin registered as a redirect URI too, never over parallel operation of both." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain]. Two values that have to agree drift apart.
|
||||
|
||||
AND THIS READ CHANGES THE CHART'S OWN ACCOUNT OF ITSELF. Before this mode
|
||||
existed, bulwark.ingress.yaml and the bulwark.publicUrl helper both stated that
|
||||
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain - true
|
||||
then, false now, because this template reads it. Both have been corrected
|
||||
rather than left standing (the same correction garage and calibre-web-automated
|
||||
needed in groups 1 and 2). The required() below is what makes it true again:
|
||||
ingress.domain is mandatory in Gateway mode too, unless hostnames are given
|
||||
explicitly.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
{{- /*
|
||||
The Ingress counterpart is `path: / , pathType: Prefix`, and "/" here is
|
||||
as permanent as it is there - see the header. The port is the Service's
|
||||
port NUMBER and not its name "http": backendRefs takes a PortNumber, so
|
||||
unlike the Ingress this reference cannot be made by name. It comes from
|
||||
the same settings helper the Service renders its port from, so
|
||||
bulwark.port and this file still cannot drift apart;
|
||||
ci/run-httproute-match-test.sh holds the pair for the case where they do.
|
||||
*/}}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port: {{ $settings.port | int64 }}
|
||||
{{- end }}
|
||||
@@ -1,77 +0,0 @@
|
||||
{{- /*
|
||||
ON BY DEFAULT (ingress.enabled: true): a webmail client nobody can reach is not
|
||||
a webmail client.
|
||||
|
||||
AT THE ROOT OF THE HOST, AND THERE IS NO VALUE TO PUT IT UNDER A PATH.
|
||||
NEXT_PUBLIC_BASE_PATH is compiled into the bundle - upstream: "These are
|
||||
compiled into the bundle and cannot be changed at runtime" - so a sub-path
|
||||
deployment needs a rebuilt image, which is outside what this repository does
|
||||
(same limit as garage #81 and keycloak #83). A rewrite-target annotation is not a
|
||||
way around it either: the bundle's own asset and API URLs are built for the root,
|
||||
so stripping a prefix at the proxy produces a page that loads and then fetches
|
||||
everything from the wrong place. The rule below is therefore "/" with pathType
|
||||
Prefix, deliberately and permanently.
|
||||
|
||||
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"),
|
||||
AND FOR THIS CHART THE GATE FREES BOTH VALUES. Checked rather than assumed:
|
||||
Bulwark derives its own public URL from the request origin and takes no
|
||||
base-URL environment variable - there is no KC_HOSTNAME equivalent here - so a
|
||||
release with no routing object at all is complete without a domain. That is the
|
||||
calibre-web-automated case rather than the matrix-synapse one.
|
||||
|
||||
CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this paragraph used to say
|
||||
that nothing outside this template FUNCTIONALLY reads ingress.domain.
|
||||
bulwark.httproute.yaml now does - as the route's hostname, with a required() of
|
||||
its own - so the value is mandatory in Gateway mode too unless
|
||||
httpRoute.hostnames is given explicitly. "The gate frees both values" means
|
||||
BOTH gates off, not this one alone.
|
||||
|
||||
THE TWO MODES ARE MUTUALLY EXCLUSIVE and the HTTPRoute template aborts the
|
||||
render when ingress.enabled and httpRoute.enabled are both true, naming both
|
||||
value paths.
|
||||
|
||||
The one other READER is NOTES.txt, which prints the access URL and the OIDC
|
||||
redirect URI. That read is deliberately NOT guarded with a required(): `helm
|
||||
template` does not render NOTES.txt, so a guard there would fire for the first
|
||||
time during a real `helm install` and never in lint, in the render test or in CI.
|
||||
The bulwark.publicUrl helper renders a visible placeholder instead, and the
|
||||
enforcement lives here - in the template that is reached by every render with the
|
||||
Ingress on, which is the default.
|
||||
*/ -}}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- /* Merged, not replaced. The one annotation worth adding here for this
|
||||
application is nginx.ingress.kubernetes.io/proxy-body-size: attachment
|
||||
uploads and the Files view go through this Ingress, and nginx's
|
||||
default of 1m rejects anything larger with a 413 that reads like an
|
||||
application fault. The chart does not set it because the right number
|
||||
is the mail server's limit, which this chart does not know. */}}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
# BY NAME, so bulwark.port and this file cannot drift apart.
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- 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 }}
|
||||
@@ -1,50 +0,0 @@
|
||||
{{- /*
|
||||
ONE claim, mounted at /app/data, holding all FOUR of Bulwark's directories as
|
||||
subdirectories of it:
|
||||
|
||||
admin config.json, policy.json, admin.json (the password hash),
|
||||
plugin-config/, plugins/, themes/, branding uploads
|
||||
admin-state admin-state.json (login timestamps), audit.log, the bootstrap
|
||||
setup token
|
||||
settings the encrypted per-account user settings
|
||||
telemetry consent and instance id
|
||||
|
||||
ONE VOLUME AND NOT FOUR, because a snapshot is atomic per volume and never
|
||||
across volumes - the durability lesson garage (#81) was built on. Split up, a
|
||||
backup can capture an audit log that records logins the configuration does not
|
||||
know about yet, or settings encrypted against a secret the configuration has
|
||||
since been repointed from. There is no argument on the other side here: this is
|
||||
a few hundred kilobytes of JSON plus an append-only log, not an object store.
|
||||
|
||||
Bulwark creates all four subdirectories itself on start (ensureConfigDir and
|
||||
ensureStateDir, both mkdir -p). It has to: the image's own `mkdir -p
|
||||
/app/data/...` ran in a layer that this mount covers. fsGroup is what makes the
|
||||
freshly provisioned volume writable for uid 1001.
|
||||
|
||||
DELIBERATELY NOT null-SAFE: an erased size yields an invalid PVC that is
|
||||
rejected at apply time, which is loud. A chart-side fallback would silently size
|
||||
somebody's volume - and on an existing release it could not help anyway, because
|
||||
a PVC cannot shrink. Same reasoning as garage's storage block and satisfactory's
|
||||
documented exception (Gitea #39).
|
||||
|
||||
NEVER RENAMED. A renamed PVC makes Kubernetes provision a new, empty volume and
|
||||
orphans the data - here that means admin.json, the only copy of the admin
|
||||
password hash, and audit.log, which by its nature cannot be reconstructed.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
accessModes:
|
||||
# ReadWriteOnce, and the single replica follows from it rather than the other
|
||||
# way round - see the bulwark.replicas helper.
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.amount }}
|
||||
{{- if .Values.storage.storageClass }}
|
||||
storageClassName: {{ .Values.storage.storageClass }}
|
||||
{{- end }}
|
||||
@@ -1,109 +0,0 @@
|
||||
{{- /*
|
||||
The 1Password items this chart materializes. TWO of them, split BY AUDIENCE
|
||||
rather than by count:
|
||||
|
||||
<release>-bulwark-app-secret this release's own secrets - the session
|
||||
secret and the admin password. Nobody else
|
||||
knows or needs them.
|
||||
<release>-bulwark-oauth-secret the OIDC client secret. NOT ours: the
|
||||
identity provider (keycloak, #83) creates it
|
||||
when the client is registered and rotates it
|
||||
there, so it is the one of the three that a
|
||||
second system also holds. A separate item is
|
||||
what lets it be repointed and rotated without
|
||||
touching the other two.
|
||||
|
||||
Same reasoning as garage's split between its RPC secret and its S3 key.
|
||||
|
||||
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
|
||||
materializes one Secret key per FIELD of the item, and this chart references
|
||||
those keys by name - two as file paths in the StatefulSet's volumeMounts, one by
|
||||
secretKeyRef. An item without the expected field leaves the container in
|
||||
CreateContainerConfigError, or - worse for the file form - produces a container
|
||||
that starts with a MISSING file, which readFileEnv answers with null rather than
|
||||
an error. NOTES.txt lists every field for the installer.
|
||||
|
||||
TWO SECRETS, TWO DIFFERENT ROTATION STORIES, and the difference is a property of
|
||||
the application rather than of this chart:
|
||||
|
||||
session-secret, oauth-client-secret FILE form. Re-read on every use with no
|
||||
cache, so a rotation is live WITHOUT a
|
||||
restart - see the whole-directory mount
|
||||
in bulwark.sfs.yaml, which is what makes
|
||||
that true.
|
||||
admin-password CONSUMED ONCE. initAdminPassword hashes
|
||||
it on the first start, writes admin.json
|
||||
to the PVC, and never looks at the
|
||||
variable again. Rotating the field after
|
||||
that has NO effect, restart or not.
|
||||
|
||||
AND THE LIMIT THAT CANNOT BE CLOSED FOR EITHER: a change to the CONTENT of an
|
||||
item restarts nothing, because Helm never sees a secret the 1Password operator
|
||||
materializes and there is nothing to hash. What this chart does cover is the
|
||||
op:// PATH, through checksum/secret-ref on the pod template. The operator's
|
||||
auto-restart annotation is NOT set here and must not be: verified against
|
||||
operator 1.12.0 it enumerates Deployments only, so on a StatefulSet it is a
|
||||
silent no-op advertising a guarantee it does not provide (README, guideline 9).
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the fields: session-secret, admin-password
|
||||
#
|
||||
# session-secret: 32 bytes of randomness, base64 - `openssl rand -base64 32`.
|
||||
# MANDATORY. Persistent "remember me" sessions and the encryption of the
|
||||
# synchronized user settings both hang on it, and the second one fails
|
||||
# SILENTLY without it: app/api/config/route.ts computes
|
||||
# `settingsSyncEnabled: configManager.get(...) && hasSessionSecret()`, so
|
||||
# bulwark.settingsSync: true plus a missing secret means synchronization is
|
||||
# simply off, with no error anywhere.
|
||||
#
|
||||
# LOSING IT LOSES DATA THAT AN INTACT VOLUME STILL HOLDS: the settings on the
|
||||
# PVC are encrypted with it. This item belongs in the backup next to the
|
||||
# volume - a backup of the volume alone is not a backup.
|
||||
#
|
||||
# admin-password: the initial password of the ADMIN DASHBOARD.
|
||||
# It is set because the dashboard is the way back out of an OIDC lockout
|
||||
# (see bulwark.oauth.only in values.yaml), not because of any secret leak -
|
||||
# measured, there is none: without ADMIN_PASSWORD Bulwark generates no
|
||||
# random password and logs nothing, it just disables the dashboard.
|
||||
#
|
||||
# CONSUMED ON THE FIRST START ONLY. After admin.json exists on the PVC this
|
||||
# field is never read again, so rotating it here changes nothing; the reset
|
||||
# goes through the dashboard or by deleting admin.json.
|
||||
itemPath: {{ required "You must provide an app secret reference (secrets.appSecretRef) - the 1Password item holding the fields 'session-secret' (openssl rand -base64 32; mandatory, the settings sync is silently disabled without it) and 'admin-password' (the admin dashboard, which is the only way back out of an OIDC lockout)" .Values.secrets.appSecretRef }}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
---
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-oauth-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the field: oauth-client-secret
|
||||
#
|
||||
# The confidential OIDC client's secret, as issued by the identity provider
|
||||
# when the client is registered. The registration is a PRE-INSTALL step, not a
|
||||
# follow-up: with bulwark.oauth.only (the default) and no working client, the
|
||||
# first sign-in is impossible. NOTES.txt prints the redirect URI to register.
|
||||
#
|
||||
# It arrives as a FILE, through OAUTH_CLIENT_SECRET_FILE, so it is not in the
|
||||
# container's process environment - which garage (#81) and keycloak (#83) could
|
||||
# not achieve because neither application offered a file form. #81's other half
|
||||
# was checked here and does not apply: lib/read-file-env.ts is readFileSync
|
||||
# plus try/catch with no mode check, so the group-read bit fsGroup adds is
|
||||
# harmless.
|
||||
#
|
||||
# AND THE ONE WAY A ROTATION SILENTLY DOES NOTHING: getGlobalClientSecret
|
||||
# checks the ADMIN OVERRIDE FIRST. If the dashboard's OAuth setup has ever
|
||||
# run, config.json on the PVC carries oauthClientSecret in PLAINTEXT and that
|
||||
# value beats this file forever. That is also why a backup of the volume can
|
||||
# contain this secret in the clear.
|
||||
itemPath: {{ required "You must provide an OAuth client secret reference (secrets.oauthSecretRef) - the 1Password item holding the field 'oauth-client-secret', as issued by the identity provider when the OIDC client was registered. Required while bulwark.oauth.enabled is true; set it to false for a password-login install" .Values.secrets.oauthSecretRef }}
|
||||
{{- end }}
|
||||
@@ -1,48 +0,0 @@
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
# The one port this application serves. targetPort addresses the container
|
||||
# port BY NAME, so bulwark.port can be changed freely while renaming the port
|
||||
# would break this and all three probes at once.
|
||||
- protocol: TCP
|
||||
port: {{ $settings.port | int64 }}
|
||||
targetPort: http
|
||||
name: http
|
||||
# ClusterIP, and no value to change it. The way out of the cluster is the
|
||||
# Ingress with a certificate: this is a webmail client that handles session
|
||||
# cookies and mail content, so a NodePort would put it on a routable address
|
||||
# over plain HTTP by accident.
|
||||
type: ClusterIP
|
||||
---
|
||||
{{/*
|
||||
The headless Service a StatefulSet needs for its serviceName. It exists because
|
||||
a StatefulSet requires one, not because anything addresses the pod by its stable
|
||||
DNS name - with one replica and no peer coordination there is nothing to address.
|
||||
Kept separate from the ClusterIP Service above so that the latter stays a plain
|
||||
load-balanced endpoint for the Ingress; pointing serviceName at a Service with a
|
||||
ClusterIP is accepted by the API server but gives the pod no per-pod DNS record,
|
||||
which is the kind of half-working arrangement that only shows up later.
|
||||
*/}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-headless
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: {{ $settings.port | int64 }}
|
||||
targetPort: http
|
||||
name: http
|
||||
@@ -1,329 +0,0 @@
|
||||
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
|
||||
{{- /*
|
||||
A StatefulSet and not a Deployment, and the reason is the single ReadWriteOnce
|
||||
PVC rather than any cluster identity: a rolling Deployment update blocks itself,
|
||||
because the new pod waits for a volume the old pod still holds. The repo's
|
||||
convention requires a StatefulSet for a stateful application anyway, and this one
|
||||
is stateful - /app/data carries the admin configuration, the admin password hash,
|
||||
the audit log and the encrypted user settings.
|
||||
|
||||
NO volumeClaimTemplates: this repo uses standalone PVC templates throughout, so
|
||||
the StatefulSet can be renamed in a major PR with a --cascade=orphan note without
|
||||
the data following the name (README, guideline 1).
|
||||
|
||||
NO INIT CONTAINER, and that is checked rather than skipped. Bulwark creates its
|
||||
four data subdirectories itself on start (ensureConfigDir / ensureStateDir, both
|
||||
mkdir -p), which it has to because the image's own mkdir ran in a layer this
|
||||
mount covers; fsGroup makes the fresh volume writable for uid 1001. There is
|
||||
nothing to seed and nothing to chown, so an init container would only be a second
|
||||
image to pin and update.
|
||||
|
||||
GUARD AT RENDER TIME, not at start: a missing OIDC client id or issuer with
|
||||
oauth.enabled would otherwise produce a release that installs cleanly, becomes
|
||||
Ready, and then answers every sign-in attempt with a broken redirect - and with
|
||||
oauth.only there is no password form to fall back on. The render fails instead,
|
||||
naming the value. It lives HERE rather than in NOTES.txt because `helm template`
|
||||
does not render NOTES.txt (guideline 12.2), so a guard there would pass every
|
||||
check in CI while being absent.
|
||||
*/ -}}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
{{- if not $settings.oauth.clientId }}
|
||||
{{- fail "bulwark.oauth.clientId is required while bulwark.oauth.enabled is true - without it the OIDC redirect is built against an empty client and every sign-in fails. With bulwark.oauth.only (the default) there is no password form to fall back to, so this would be an install nobody can log into. Register the client with the identity provider first; NOTES.txt prints the redirect URI." }}
|
||||
{{- end }}
|
||||
{{- if not $settings.oauth.issuerUrl }}
|
||||
{{- fail "bulwark.oauth.issuerUrl is required while bulwark.oauth.enabled is true - Bulwark fetches the OIDC discovery document from it, and without it there is nothing to discover. For keycloak this is https://<host>/realms/<realm>." }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if not $settings.jmapServerUrl }}
|
||||
{{- /* Not merely "the mail server is unset": an empty JMAP_SERVER_URL puts
|
||||
detectSetupState() into "bootstrap" (lib/setup/state.ts), which serves the
|
||||
FIRST-RUN WIZARD at /setup and 302s everything else there. The wizard then
|
||||
writes its result into config.json on the PVC - and from that moment the
|
||||
volume beats every value in this chart, permanently and silently. So an
|
||||
unset value here does not produce a non-working release, it produces a
|
||||
SILENTLY DIFFERENT ONE, which is worse. */}}
|
||||
{{- fail "bulwark.jmapServerUrl is required - it is the address of the Stalwart server AND the switch that disables the first-run setup wizard. Left empty, Bulwark starts in bootstrap mode, serves its wizard at /setup, and whatever is clicked through there lands in config.json on the PVC - which from then on OVERRIDES every value this chart sets, silently. Set it to the stalwart release's JMAP endpoint." }}
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
replicas: {{ include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas) }}
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-headless
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
# NO checksum/config ANNOTATION, AND THAT IS NOT AN OMISSION (#82).
|
||||
# This chart renders no ConfigMap: Bulwark is configured through
|
||||
# environment variables plus the two JSON files it writes itself onto the
|
||||
# PVC. There is no rendered config file whose change could fail to
|
||||
# restart the workload, so the rule has nothing to attach to. A changed
|
||||
# environment variable changes the pod template directly and rolls the
|
||||
# pod, which is what the annotation exists to achieve elsewhere.
|
||||
#
|
||||
# Hash of the rendered OnePasswordItems - the op:// PATHS, not their
|
||||
# contents. Repointing secrets.appSecretRef or secrets.oauthSecretRef at
|
||||
# a different vault item therefore rolls the pod instead of leaving the
|
||||
# old credentials in the running process. A change INSIDE an item is not
|
||||
# covered and cannot be, because Helm never sees it - but for the two
|
||||
# FILE-form secrets it does not need to be: both are re-read on every use
|
||||
# (see the secrets volume below), so a rotation is live without a
|
||||
# restart. The exception is admin-password, which is consumed exactly
|
||||
# once on the first start and which no restart brings back.
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/bulwark.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "bulwark.podSecurityContext" $.Values.bulwark.securityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
# Fully qualified. The suffix-free tag family, see Chart.yaml - this
|
||||
# registry publishes every release twice (1.12.0 and v1.12.0) and the
|
||||
# chart pins the form without the "v".
|
||||
image: "ghcr.io/bulwarkmail/webmail:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
# No command and no args: the image's CMD is ["node", "server.js"] with
|
||||
# WORKDIR /app, and the working directory is load-bearing here rather
|
||||
# than incidental - Bulwark resolves its data directories relative to
|
||||
# process.cwd(), so overriding either would move /app/data out from
|
||||
# under the volume mount.
|
||||
env:
|
||||
# THE MAIL SERVER, AND THE SWITCH THAT KILLS THE SETUP WIZARD. With
|
||||
# this set, detectSetupState() returns "env-managed" and /setup is
|
||||
# not served at all, which is what makes this chart declarative.
|
||||
#
|
||||
# IT IS STILL BEATEN BY A SERVER URL SAVED IN THE ADMIN DASHBOARD -
|
||||
# the one precedence trap upstream documents itself. See values.yaml.
|
||||
- name: JMAP_SERVER_URL
|
||||
value: {{ $settings.jmapServerUrl | quote }}
|
||||
{{- with $settings.appName }}
|
||||
- name: APP_NAME
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: STALWART_FEATURES
|
||||
value: {{ $settings.stalwartFeatures | quote }}
|
||||
- name: STALWART_ADMIN_ACCESS
|
||||
value: {{ $settings.stalwartAdminAccess | quote }}
|
||||
# The settings sync. Note that this flag alone is not enough: without
|
||||
# a session secret, app/api/config/route.ts turns it off SILENTLY
|
||||
# (`configManager.get(...) && hasSessionSecret()`). The secret below
|
||||
# is therefore mandatory rather than recommended.
|
||||
- name: SETTINGS_SYNC_ENABLED
|
||||
value: {{ $settings.settingsSync | quote }}
|
||||
# THE SESSION SECRET, AS A FILE PATH. Everything about why it is the
|
||||
# file form and why the mount has no subPath is at the volume below.
|
||||
#
|
||||
# This is also ONE OF ONLY TWO VALUES IN THIS CHART THAT THE VOLUME
|
||||
# CANNOT OVERRIDE: getSessionSecret (lib/auth/session-secret.ts) reads
|
||||
# SESSION_SECRET, then SESSION_SECRET_FILE, and only then the admin
|
||||
# override - the reverse of configManager.get's normal order.
|
||||
- name: SESSION_SECRET_FILE
|
||||
value: {{ include "bulwark.secretMountPath" . }}/session-secret
|
||||
# THE ADMIN PASSWORD, AS AN ENVIRONMENT VARIABLE, AND THAT IS FORCED
|
||||
# RATHER THAN CHOSEN: ADMIN_PASSWORD has no _FILE form in
|
||||
# CONFIG_ENV_MAP, unlike the other two secrets here. The residual
|
||||
# price, stated rather than hidden: it is in the container's process
|
||||
# environment, so anyone who can exec into the pod or read its /proc
|
||||
# can see it until the first start has hashed it into admin.json.
|
||||
#
|
||||
# WHY IT IS SET AT ALL: the admin dashboard is the only way back out
|
||||
# of an OIDC lockout (OAUTH_ONLY below), and without this variable
|
||||
# the dashboard is simply disabled - there is no random password and
|
||||
# nothing in the log, measured in lib/admin/password.ts.
|
||||
- name: ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
key: admin-password
|
||||
{{- if $settings.adminConfigReadOnly }}
|
||||
# THE CHART IS AUTHORITATIVE FROM HERE ON - and this is the one value
|
||||
# whose price is bigger than its name. It freezes the settings policy
|
||||
# (and with it the only way to move Web Push off the vendor relay),
|
||||
# it freezes an override that ALREADY exists instead of removing it,
|
||||
# and on a FRESH install it prevents admin.json from being written at
|
||||
# all - which takes the admin dashboard, i.e. the rescue door, away
|
||||
# entirely while the pod stays Ready. values.yaml has the measurement
|
||||
# and the only safe sequence.
|
||||
- name: ADMIN_CONFIG_READONLY
|
||||
value: "true"
|
||||
{{- end }}
|
||||
{{- if $settings.oauth.enabled }}
|
||||
- name: OAUTH_ENABLED
|
||||
value: "true"
|
||||
# HIDES THE PASSWORD FORM. The way back out is NOT a helm upgrade
|
||||
# with this false - oauthOnly is read through configManager.get by
|
||||
# every consumer, so an override in config.json on the PVC wins. The
|
||||
# repair is removing that override in the admin dashboard. NOTES.txt
|
||||
# carries it.
|
||||
- name: OAUTH_ONLY
|
||||
value: {{ $settings.oauth.only | quote }}
|
||||
- name: OAUTH_CLIENT_ID
|
||||
value: {{ $settings.oauth.clientId | quote }}
|
||||
- name: OAUTH_ISSUER_URL
|
||||
value: {{ $settings.oauth.issuerUrl | quote }}
|
||||
# The client secret as a FILE, so it stays out of the process
|
||||
# environment - the better path garage (#81) and keycloak (#83) did
|
||||
# not have. Re-read on every token exchange, so a rotation needs no
|
||||
# restart; see the volume below for the subPath condition that makes
|
||||
# that true.
|
||||
- name: OAUTH_CLIENT_SECRET_FILE
|
||||
value: {{ include "bulwark.secretMountPath" . }}/oauth-client-secret
|
||||
{{- with $settings.oauth.scopes }}
|
||||
- name: OAUTH_SCOPES
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: OAUTH_END_SESSION
|
||||
value: {{ $settings.oauth.endSession | quote }}
|
||||
{{- end }}
|
||||
# Both OFF against the image's defaults, and deliberately: a mail
|
||||
# client should not phone home from a private cluster unless somebody
|
||||
# chose it, and Renovate already watches the image tag, so the
|
||||
# version check would answer outbound a question already answered
|
||||
# inbound.
|
||||
- name: BULWARK_TELEMETRY
|
||||
value: {{ ternary "on" "off" $settings.telemetry | quote }}
|
||||
- name: BULWARK_UPDATE_CHECK
|
||||
value: {{ ternary "on" "off" $settings.updateCheck | quote }}
|
||||
- name: LOG_LEVEL
|
||||
value: {{ $settings.logLevel | quote }}
|
||||
- name: LOG_FORMAT
|
||||
value: {{ $settings.logFormat | quote }}
|
||||
# PORT and HOSTNAME are already set in the image (3000 / 0.0.0.0);
|
||||
# PORT is repeated here only because bulwark.port makes it
|
||||
# configurable, and the containerPort below has to agree with it.
|
||||
- name: PORT
|
||||
value: {{ $settings.port | quote }}
|
||||
{{- with $.Values.bulwark.env }}
|
||||
{{- include "bulwark.env" (dict "root" $ "env" .) | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
# The name "http" is what the Service and all three probes reference,
|
||||
# so renaming it here breaks four things at once.
|
||||
- name: http
|
||||
containerPort: {{ $settings.port | int64 }}
|
||||
protocol: TCP
|
||||
# Probes, resources and both security contexts go through the null-safe
|
||||
# helpers (#99): an erased block means "chart defaults apply" instead of
|
||||
# a vanished probe or an unhardened container. Never wrap these in an
|
||||
# "if" - a guard hides the erased case completely instead of rendering a
|
||||
# visible null.
|
||||
#
|
||||
# All three are an HTTP GET on /api/health - not HEAD, because a
|
||||
# Kubernetes probe cannot send one (HTTPGetAction has no method field).
|
||||
# What that endpoint can and cannot report is argued at the helpers and
|
||||
# at bulwark.readinessProbe in values.yaml; the short version is that it
|
||||
# carries the process, HTTP acceptance, a free event loop and Next.js
|
||||
# routing - and NOTHING about the mail server.
|
||||
livenessProbe:
|
||||
{{- include "bulwark.livenessProbe" $.Values.bulwark.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "bulwark.readinessProbe" $.Values.bulwark.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "bulwark.startupProbe" $.Values.bulwark.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "bulwark.effectiveResources" $.Values.bulwark.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "bulwark.containerSecurityContext" $.Values.bulwark.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# THE ONE PVC, carrying all four of Bulwark's directories as
|
||||
# subdirectories so that a volume snapshot catches configuration,
|
||||
# runtime state, audit log and user settings atomically.
|
||||
- name: data
|
||||
mountPath: {{ include "bulwark.dataDir" . }}
|
||||
# THE TWO FILE-FORM SECRETS, AS A WHOLE DIRECTORY AND NOT WITH
|
||||
# subPath. This is the line a later round will want to "tidy up" into
|
||||
# two subPath mounts, and that would silently break secret rotation:
|
||||
# the kubelet never refreshes a subPath mount, while both values are
|
||||
# re-read from disk on every use (getSessionSecret and
|
||||
# getGlobalClientSecret both call readFileEnv with no cache). With
|
||||
# this mount a rotated vault field is live without a restart; with
|
||||
# subPath the container would hold the first value forever and
|
||||
# nothing would report it.
|
||||
- name: secrets
|
||||
mountPath: {{ include "bulwark.secretMountPath" . }}
|
||||
readOnly: true
|
||||
# The two writable paths outside the volume, needed because
|
||||
# readOnlyRootFilesystem is true. Both are CACHES, not state - see
|
||||
# bulwark.writableCache in values.yaml for how the set was
|
||||
# established and why neither belongs on the PVC.
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: next-cache
|
||||
mountPath: /app/.next/cache
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: secrets
|
||||
# One projected volume over the two Secrets the 1Password operator
|
||||
# materializes, so both files sit in one directory and the container
|
||||
# needs one mount instead of two.
|
||||
#
|
||||
# defaultMode 0440 - owner and GROUP read, and the group bit is the
|
||||
# part that must not be "tightened" to 0400. Files in a secret volume
|
||||
# are owned by ROOT, not by the container's user; what gives uid 1001
|
||||
# access is fsGroup, which the kubelet applies by chowning the files to
|
||||
# group 1001. Without a group read bit, uid 1001 could not read either
|
||||
# file - and the failure would be silent, because readFileEnv answers
|
||||
# an EACCES with null exactly as it answers a missing file: the
|
||||
# settings sync would switch itself off and every token exchange would
|
||||
# send an empty client secret.
|
||||
#
|
||||
# (The kubelet ORs 0440 onto the mode of a secret volume once fsGroup
|
||||
# is set, so 0400 would in practice be corrected to 0440 anyway. It is
|
||||
# written out because a mode that only works because something else
|
||||
# silently fixes it is not a mode anyone should have to re-derive.)
|
||||
#
|
||||
# #81's permission trap does not apply in the other direction either:
|
||||
# lib/read-file-env.ts is readFileSync plus try/catch with no mode
|
||||
# check, so unlike garage, Bulwark does not REJECT a group-readable
|
||||
# secret file. That is what makes the file form usable here at all.
|
||||
projected:
|
||||
defaultMode: 0440
|
||||
sources:
|
||||
- secret:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-app-secret
|
||||
items:
|
||||
# Only the session secret becomes a file. admin-password is
|
||||
# in the same Secret but arrives as an environment variable
|
||||
# (it has no _FILE form), so projecting it here as well would
|
||||
# put it on disk for no reader.
|
||||
- key: session-secret
|
||||
path: session-secret
|
||||
# optional: the 1Password operator fills this Secret in-cluster
|
||||
# AFTER Helm has applied the manifests, so on a first install
|
||||
# the Secret does not exist yet. Without this the pod would sit
|
||||
# in ContainerCreating until the operator caught up, and with a
|
||||
# misconfigured operator it would sit there forever with the
|
||||
# reason only in the pod's events. It starts instead, and the
|
||||
# missing secret surfaces where it is actionable: the settings
|
||||
# sync stays off and the log says so.
|
||||
optional: true
|
||||
{{- if $settings.oauth.enabled }}
|
||||
- secret:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-oauth-secret
|
||||
items:
|
||||
- key: oauth-client-secret
|
||||
path: oauth-client-secret
|
||||
optional: true
|
||||
{{- end }}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: {{ $settings.writableCache.tmpSizeLimit }}
|
||||
# Next.js's own cache directory in standalone mode - the optimized-image
|
||||
# cache and the fetch cache. It is a SUBDIRECTORY of .next, which is what
|
||||
# makes this possible at all: /app/.next/static holds the build output and
|
||||
# has to stay readable, and mounting the cache path does not shadow it.
|
||||
- name: next-cache
|
||||
emptyDir:
|
||||
sizeLimit: {{ $settings.writableCache.nextCacheSizeLimit }}
|
||||
@@ -1,367 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "bulwark values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.appSecretRef, bulwark.jmapServerUrl and the two OIDC values all ship as null and JSON Schema's 'required' tests presence, not null-ness, so a 'required' array would never fire - the enforcement is required() in bulwark.secret.yaml and fail() in bulwark.sfs.yaml (issue #18). And note what no schema can express at all for this chart: an admin override in config.json on the PVC beats every value below, silently (values.yaml, point 1).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"global": {
|
||||
"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"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'session-secret' and 'admin-password'. Required always. The session secret is mandatory rather than recommended: without it the settings sync is switched off SILENTLY, and the encrypted settings already on the volume become unreadable. The admin password is consumed exactly once, on the first start.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"oauthSecretRef": {
|
||||
"description": "op:// item path. The item must carry the field 'oauth-client-secret', as issued by the identity provider when the OIDC client was registered. Required while bulwark.oauth.enabled is true.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulwark": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "0 or 1. Capped at 1 because of the single ReadWriteOnce PVC, not because of the application: two pods cannot both mount it, so a second replica would sit in ContainerCreating forever. An RWX volume would not help either - nothing coordinates two processes over that directory, and the admin config is read into memory once at start.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"port": {
|
||||
"$ref": "#/definitions/port"
|
||||
},
|
||||
"jmapServerUrl": {
|
||||
"description": "The Stalwart JMAP endpoint. Required - and it does two things: it is the mail server's address AND the switch that disables the first-run setup wizard. Left empty, Bulwark starts in bootstrap mode and whatever is clicked through its wizard lands in config.json on the PVC, which then overrides every value here. Enforced by fail() in the StatefulSet, not by this schema (see the title).",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"appName": {
|
||||
"description": "APP_NAME - the product name in the UI and the PWA. null leaves upstream's 'Webmail'.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"stalwartFeatures": {
|
||||
"description": "STALWART_FEATURES - the Stalwart-specific UI parts (password change, Sieve rules). Does not gate the server-side credential passthrough, which has its own upstream switch this chart does not render.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stalwartAdminAccess": {
|
||||
"description": "STALWART_ADMIN_ACCESS - who may open the admin dashboard, which after bulwark.oauth.only is the only way back into a broken login path. 'auto' (upstream's default, kept here) signs a Stalwart admin in without the Bulwark admin password; 'password' requires it; 'off' ignores Stalwart admin status entirely. The consequence of 'auto' is at the value in values.yaml: a Stalwart admin can write the config overrides that disconnect this chart from its own values.",
|
||||
"type": "string",
|
||||
"enum": ["auto", "password", "off"]
|
||||
},
|
||||
"settingsSync": {
|
||||
"description": "SETTINGS_SYNC_ENABLED - per-account preferences follow the user instead of living in one browser. true means /app/data/settings on the PVC carries USER DATA, encrypted with the session secret. Without that secret this switch is ignored, silently.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"adminConfigReadOnly": {
|
||||
"description": "ADMIN_CONFIG_READONLY - refuse every write to the admin config directory at the application layer, which makes this file authoritative and closes the precedence trap for good. FALSE by default because the price is threefold and measured: it also freezes the settings policy (and with it the only way to move Web Push off the vendor relay); it freezes an override that ALREADY exists rather than removing it, because removeAdminOverride is guarded by the same check; and on a FRESH install it prevents admin.json from being written at all, so the admin dashboard - the rescue door - never comes up and its login endpoint answers HTTP 500 while the pod stays Ready. values.yaml has the only safe sequence.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"telemetry": {
|
||||
"description": "BULWARK_TELEMETRY. false. Upstream's default is already off (telemetry is opt-in), so what this buys is the LOCK: the environment beats the state file and upstream blocks the admin UI from changing it while the override is active.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateCheck": {
|
||||
"description": "BULWARK_UPDATE_CHECK - the hourly version check. Unlike telemetry this is ON unless switched off, so false does change behaviour. Off because Renovate already watches the image tag inbound.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logLevel": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warn", "info", "debug"]
|
||||
},
|
||||
"logFormat": {
|
||||
"description": "text or json. json here, against upstream's default of text, because the logs go to a cluster log collector rather than to a terminal.",
|
||||
"type": "string",
|
||||
"enum": ["text", "json"]
|
||||
},
|
||||
"oauth": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults (OIDC on, password form hidden). That fallback is deliberate and is the safe direction - an erased block must not silently turn the whole sign-in path off on a release whose login is OIDC.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "OAUTH_ENABLED. Gates the OIDC values, the OAuth OnePasswordItem and the secret file mount.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"only": {
|
||||
"description": "OAUTH_ONLY - hide the password form, so the identity provider is the only way in for a user. This chart's riskiest default, taken deliberately. The way back out of a lockout is NOT a helm upgrade with this false: oauthOnly is read through configManager.get, so an override in config.json on the PVC wins. The repair is removing that override in the admin dashboard.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "OAUTH_CLIENT_ID. Required while enabled - enforced by fail() in the StatefulSet.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"issuerUrl": {
|
||||
"description": "OAUTH_ISSUER_URL, the issuer Bulwark fetches the discovery document from (keycloak: https://<host>/realms/<realm>). Required while enabled - enforced by fail() in the StatefulSet. The endpoints it advertises must share the issuer's origin or be public, or Bulwark's discovery validator rejects them.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"scopes": {
|
||||
"description": "OAUTH_SCOPES - REPLACES the scope list rather than adding to it. null leaves upstream's default, which is the right starting point: an incomplete list here is a login that succeeds and then has no identity to work with. Use OAUTH_EXTRA_SCOPES through bulwark.env to add.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"endSession": {
|
||||
"description": "OAUTH_END_SESSION - signing out also ends the provider's session. true (upstream's default): on a shared browser a sign-out that leaves the provider signed in means the next click is straight back in.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"writableCache": {
|
||||
"description": "The sizeLimit of the two emptyDirs that readOnlyRootFilesystem: true makes necessary - /tmp and Next.js's own cache at /app/.next/cache. Both are CACHES, not state: losing them on a restart costs one round of image re-optimization. They belong here rather than on the PVC, because a cache on a ReadWriteOnce network volume is slower than regenerating it and would grow the only volume that has to be backed up. These limits and the pod's ephemeral-storage request under bulwark.resources are two halves of one accounting and should move together.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tmpSizeLimit": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"nextCacheSizeLimit": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/securityContext"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"description": "The single PVC at /app/data, carrying all four of Bulwark's directories as subdirectories - one volume, because a snapshot is atomic per volume and never across volumes (the lesson of Gitea #81). Deliberately NOT null-safe: an erased size yields an invalid PVC, which fails loudly at apply time. Issue #99 covers only the silent class, and on an existing release a chart-side fallback could not help anyway, because a PVC cannot shrink.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": {
|
||||
"description": "What actually grows here is audit.log, and it grows with admin activity rather than with mail volume - the application does not rotate it.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"storageClass": {
|
||||
"description": "null uses the cluster's default StorageClass. ReadWriteOnce is enough and is what the single replica needs.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart, and CORRECTED with Gitea #114: it used to say that nothing outside that template functionally reads ingress.domain, which the HTTPRoute now does. Switching the INGRESS off frees clusterIssuer; it frees domain only if httpRoute is off as well. What still holds is the reason - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so a release with no routing object at all is complete without a domain. NOTES.txt renders a visible placeholder rather than carrying a required() that helm template could never reach.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"domain": {
|
||||
"description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root - which is also why the HTTPRoute renders no URLRewrite filter. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by NOTES.txt for the OIDC redirect URI.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault - and that is what this installation's live Ingress actually carries (25m, measured 2026-10-12). INERT IN GATEWAY MODE: the Gateway API has no annotation channel and no body-size counterpart at all.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. ONE rule, at the root, with no URLRewrite filter - Bulwark cannot be served under a sub-path, so there is nothing to strip and the #118 prefix/redirect assurance has no precondition to meet here. NOT COVERED IN THIS MODE: proxy-body-size, which this installation's live Ingress carries at 25m and which has no counterpart in the specification - an oversized attachment is then rejected by the Gateway with a 413 that reads like a webmail fault. See values.yaml, httpRoute block. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/parentRef" }
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Setting it means a second origin, which has to be registered as an OIDC redirect URI with the identity provider before a login can succeed on it.",
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"port": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting. One thing specific to this chart: the memory LIMIT is what V8's heap limit is derived from, and /api/health reports 'degraded' once heap usage crosses its threshold - a report the probes cannot see, so a limit set too low produces a pod that is permanently degraded and permanently Ready.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limitFactor": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"requests": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceList": {
|
||||
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"memory": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeral-storage": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeralStorage": {
|
||||
"type": ["string", "number", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"description": "Additive container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success. And a second caveat specific to this application: an environment variable is only the MIDDLE of three layers, so anything set here is a default that an admin override in config.json on the PVC may already be beating, silently.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"secretKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
},
|
||||
"configMapKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, deviations included - uid/gid/fsGroup 1001 rather than this repo's 20000, because that is the user the image creates (addgroup --gid 1001 nodejs / adduser --uid 1001 nextjs) and the owner of /app/data in the image layer. fsGroupChangePolicy is the repo's 'Always' and is correct here rather than merely default: /app/data holds a few JSON files and an append-only log, so the recursive chown is cheap, and it is what makes a freshly provisioned volume writable on the first start.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pod": {
|
||||
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"container": {
|
||||
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"probe": {
|
||||
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. All three defaults carry the same CONTEXT: an HTTP GET on /api/health, addressed at the port NAME 'http'. Not HEAD, although Bulwark offers one and it would be cheaper - a Kubernetes HTTPGetAction has no method field and the kubelet always issues a GET, and the only alternative (an exec probe) would mean a Node process every few seconds in an image with no curl or wget. What that proves and what it does not is measured rather than read off the path - it carries the process, HTTP acceptance, a free event loop and Next.js routing, and it carries NOTHING about the reported application state (the handler returns a hard 200 and hard-codes the body to 'healthy' for an unauthenticated caller) and NOTHING about Stalwart (with the mail server gone the probe is green and the pod stays Ready). There is no better endpoint: /api/setup/test-jmap would really test the connection but sits behind the bootstrap setup token, which this chart disables.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,900 +0,0 @@
|
||||
# Bulwark Webmail - a JMAP webmail client (mail, calendar, contacts, files) for
|
||||
# a Stalwart mail server. It is a CLIENT: it holds no mail, has no database, no
|
||||
# cache and no broker. The messages, the accounts and SMTP are stalwart's
|
||||
# (Gitea #65), reached over the network by bulwark.jmapServerUrl.
|
||||
#
|
||||
# READ THIS FIRST. Four properties shape every value below, and not one of them
|
||||
# is visible from the outside (Gitea #89). Everything here was measured against
|
||||
# the source at tag 1.12.0, not taken from the documentation - the documentation
|
||||
# was wrong about two of them.
|
||||
#
|
||||
# 1. THE VOLUME BEATS THE CHART, FOR EVERY VALUE, SILENTLY.
|
||||
# This is the single most important sentence in this file.
|
||||
# lib/admin/config-manager.ts resolves a config value in this order:
|
||||
#
|
||||
# admin override (config.json on the PVC) > environment variable > default
|
||||
#
|
||||
# Every setting this chart renders as an environment variable is therefore a
|
||||
# DEFAULT that the admin dashboard may already be overriding. The override is
|
||||
# a key in config.json on the PVC, the dashboard may write ANY key of
|
||||
# CONFIG_ENV_MAP (app/api/admin/config/route.ts validates against exactly
|
||||
# that key list), and nothing in the dashboard says that it has just
|
||||
# disconnected the chart. A `helm upgrade` with a changed value then changes
|
||||
# nothing, reports success, and leaves no trace of why.
|
||||
#
|
||||
# The documentation names this only for the server URL ("A server URL saved
|
||||
# by the wizard takes priority over the environment variable"). It is not
|
||||
# special to the server URL. It is the behaviour of every key.
|
||||
#
|
||||
# THERE ARE EXACTLY THREE EXCEPTIONS, all verified, and they are the only
|
||||
# values in this chart that the volume cannot silently beat:
|
||||
# - SESSION_SECRET / SESSION_SECRET_FILE: getSessionSecret()
|
||||
# (lib/auth/session-secret.ts) reads the environment FIRST and the admin
|
||||
# override last - the reverse of the normal order.
|
||||
# - ADMIN_PASSWORD: initAdminPassword() reads the environment only. (It is
|
||||
# still consumed exactly once - see secrets.appSecretRef.)
|
||||
# - BULWARK_TELEMETRY: effectiveConsent() takes the environment over the
|
||||
# state file, and upstream blocks the admin UI from changing it while the
|
||||
# override is set.
|
||||
# The OAuth client secret is NOT an exception, which is the one most likely to
|
||||
# be assumed - see secrets.oauthSecretRef.
|
||||
#
|
||||
# The way out, if you want the chart to be authoritative, is
|
||||
# bulwark.adminConfigReadOnly - and it has a price that is bigger than it
|
||||
# looks. Read it there before setting it.
|
||||
#
|
||||
# 2. THERE IS NO ConfigMap IN THIS CHART, AND THAT IS NOT AN OMISSION.
|
||||
# Bulwark is configured entirely through environment variables plus the two
|
||||
# JSON files it writes itself onto the PVC (config.json, policy.json). No
|
||||
# config file is rendered, mounted or hashed here, so the repo's
|
||||
# checksum/config rule (#82) has nothing to attach to: there is no ConfigMap
|
||||
# whose change could fail to restart the workload. What IS covered is the
|
||||
# op:// PATHS of the vault items, through checksum/secret-ref on the pod
|
||||
# template, so repointing a secrets.* value really does roll the pod. A
|
||||
# change INSIDE an item is not covered and cannot be - Helm never sees it -
|
||||
# and for the two FILE-form secrets it does not need to be: both are re-read
|
||||
# on every use, so a rotation takes effect without a restart (point 3).
|
||||
#
|
||||
# 3. THE TWO FILE-FORM SECRETS ARE MOUNTED WITHOUT subPath, ON PURPOSE.
|
||||
# SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE both exist (upstream calls
|
||||
# them the file form of SESSION_SECRET and OAUTH_CLIENT_SECRET), and taking
|
||||
# them keeps both values out of the container's process environment, which is
|
||||
# what garage (#81) and keycloak (#83) could not do because neither
|
||||
# application offered a file form at all. Here the file form is also
|
||||
# ROTATABLE: readFileEnv() is called on every use with no cache, so a changed
|
||||
# field in the vault item takes effect without a pod restart - but only
|
||||
# because the Secret is mounted as a whole directory. The kubelet never
|
||||
# refreshes a subPath mount, so a subPath here would silently freeze both
|
||||
# secrets at their first value while everything else looked correct.
|
||||
# ADMIN_PASSWORD has NO file form and stays an environment variable.
|
||||
#
|
||||
# 4. THREE OPTIONS ARE BAKED IN AT BUILD TIME AND THIS CHART CANNOT MOVE THEM.
|
||||
# Upstream: "These are compiled into the bundle and cannot be changed at
|
||||
# runtime ... To change build-time variables with the published Docker image,
|
||||
# rebuild with matching --build-arg." Building our own image is outside what
|
||||
# this repository does - the same limit as garage (#81) and keycloak (#83).
|
||||
# - NEXT_PUBLIC_BASE_PATH: so Bulwark is served at the ROOT of a hostname.
|
||||
# There is no sub-path deployment and no value here that could make one.
|
||||
# - NEXT_PUBLIC_DEFAULT_LOCALE: the fallback UI language stays "en". A
|
||||
# visitor with a matching Accept-Language gets their own language;
|
||||
# everybody else gets English.
|
||||
# - NEXT_PUBLIC_PUSH_RELAY_URL: see bulwark.pushRelay below. This one is
|
||||
# NOT the disclosure it looks like.
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# the StatefulSet with replicas: 0 and takes precedence over bulwark.replicas.
|
||||
# The PVC, the Secrets, the Service and the Ingress stay in place;
|
||||
# scaleDown: false brings the workload back up.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item carrying THIS RELEASE'S OWN secrets.
|
||||
# REQUIRED - the chart does not render without it.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# session-secret -> mounted as a file, read via SESSION_SECRET_FILE
|
||||
# admin-password -> ADMIN_PASSWORD (environment variable, no file form)
|
||||
#
|
||||
# WHY session-secret IS MANDATORY AND NOT OPTIONAL. Two things hang on it, and
|
||||
# one of them fails silently without it: persistent "remember me" sessions,
|
||||
# and the encryption of the synchronized user settings. app/api/config/route.ts
|
||||
# computes `settingsSyncEnabled: configManager.get(...) && hasSessionSecret()`
|
||||
# - so with bulwark.settingsSync true and no secret, synchronization is
|
||||
# switched OFF with no error anywhere. Generate it with
|
||||
# openssl rand -base64 32
|
||||
#
|
||||
# AND THE BACKUP STATEMENT, WHICH IS TWO-PART AND IS THE SHARPEST THING ON
|
||||
# THIS PAGE. It belongs wherever this release's backups are described:
|
||||
#
|
||||
# (a) A BACKUP OF THE VOLUME ALONE IS NOT A BACKUP. The synchronized
|
||||
# settings on the PVC are encrypted with session-secret. Lose the vault
|
||||
# item and they are unreadable out of a perfectly intact volume. The
|
||||
# secret has to be in the backup just as much as the volume is.
|
||||
# (b) A BACKUP OF THE VOLUME MAY CONTAIN A SECRET IN CLEARTEXT. If anybody
|
||||
# has ever used the admin dashboard's OAuth setup,
|
||||
# app/api/admin/oauth/setup/route.ts has written oauthClientSecret as
|
||||
# PLAINTEXT into config.json on the PVC. So the volume backup is not
|
||||
# merely useless without the vault - it may itself need the protection
|
||||
# the vault gets.
|
||||
#
|
||||
# WHY admin-password IS SET AT ALL, and the reason is NOT the one the issue
|
||||
# originally gave. Measured: without ADMIN_PASSWORD, Bulwark does NOT generate
|
||||
# a random password and writes NOTHING to stdout - lib/admin/password.ts
|
||||
# simply logs "Admin dashboard disabled (no ADMIN_PASSWORD set)" and the
|
||||
# dashboard stays off. (The only random value in the project is the bootstrap
|
||||
# setup token, which is written into the state directory and never logged.) So
|
||||
# there is no leak into the pod log here and the analogy to stalwart's
|
||||
# STALWART_RECOVERY_ADMIN (#65) does not hold.
|
||||
#
|
||||
# The reason it is set is point 1 at the top of this file combined with
|
||||
# bulwark.oauth.only: THE ADMIN DASHBOARD IS THE ONLY WAY BACK OUT OF AN
|
||||
# OIDC LOCKOUT. Switching it off means taking away the door.
|
||||
#
|
||||
# THE FIELD IS CONSUMED EXACTLY ONCE, which is the part that surprises people
|
||||
# later: initAdminPassword() returns as soon as admin.json exists on the PVC
|
||||
# and never looks at the variable again; on the first start it hashes it with
|
||||
# scrypt and persists the hash. ROTATING THE VAULT FIELD AFTERWARDS HAS NO
|
||||
# EFFECT. A reset goes through the dashboard itself, or by deleting admin.json
|
||||
# from the PVC. Same cause as point 1: the volume beats the chart.
|
||||
appSecretRef: null
|
||||
|
||||
# op:// path of the 1Password item carrying the OIDC CLIENT SECRET.
|
||||
# Required while bulwark.oauth.enabled is true (the default).
|
||||
#
|
||||
# Must carry the field:
|
||||
# oauth-client-secret -> mounted as a file, read via OAUTH_CLIENT_SECRET_FILE
|
||||
#
|
||||
# A SEPARATE ITEM FROM appSecretRef, BY AUDIENCE: this value is not ours, it
|
||||
# is created by the identity provider when the client is registered (keycloak,
|
||||
# #83), it is rotated there, and it is the one of the three that another
|
||||
# system also knows. Two items mean it can be repointed and rotated without
|
||||
# touching this release's own secrets. Same reasoning as garage's split
|
||||
# between its RPC secret and its S3 key.
|
||||
#
|
||||
# ROTATION NEEDS NO RESTART - with one condition the chart satisfies.
|
||||
# getGlobalClientSecret() (lib/oauth/token-exchange.ts) calls readFileEnv on
|
||||
# EVERY token exchange with no cache, so a new value in the vault is live as
|
||||
# soon as the operator has written it. The condition is the whole-directory
|
||||
# mount of point 3; a subPath mount would never be refreshed.
|
||||
#
|
||||
# AND THE ONE WAY A ROTATION STILL DOES NOTHING, which is point 1 again and is
|
||||
# why this is not filed under "rotation just works": getGlobalClientSecret
|
||||
# checks the ADMIN OVERRIDE FIRST. If the admin dashboard's OAuth setup has
|
||||
# ever run, config.json on the PVC carries oauthClientSecret in plaintext and
|
||||
# that value wins over this file - forever, and with no error. The repair is
|
||||
# the same as for every other override: remove it in the dashboard (see
|
||||
# bulwark.oauth.only).
|
||||
#
|
||||
# #81 DOES NOT APPLY HERE, and it was checked rather than assumed, because it
|
||||
# is exactly what forced garage onto an environment variable: lib/read-file-env.ts
|
||||
# is readFileSync plus try/catch and nothing else - no statSync, no
|
||||
# `mode & 0o077` permission check. The group-read bit the kubelet ORs onto
|
||||
# every file of a secret volume once fsGroup is set is therefore harmless, and
|
||||
# the file form is load-bearing rather than merely available.
|
||||
oauthSecretRef: null
|
||||
|
||||
bulwark:
|
||||
# ONE, and the schema caps it there. The reason is this chart's single
|
||||
# ReadWriteOnce PVC, not anything about the application: two pods cannot both
|
||||
# mount it, so a second replica would sit in ContainerCreating forever. An RWX
|
||||
# volume would not help either - nothing in Bulwark coordinates two processes
|
||||
# over that directory, and the admin config is read into memory once at start
|
||||
# (configManager.load() in instrumentation.node.ts), so two pods would hold
|
||||
# two divergent views of it.
|
||||
#
|
||||
# An explicit 0 is allowed and is the per-workload way to stop the release;
|
||||
# the chart-global scaleDown flag does the same for everything at once.
|
||||
replicas: 1
|
||||
|
||||
# THE JMAP SERVER - the stalwart release this client talks to. REQUIRED, and
|
||||
# it does two things at once, which is why it has no default.
|
||||
#
|
||||
# (a) it is the address of the mail server. Without it there is no mail.
|
||||
# (b) IT SWITCHES OFF THE FIRST-RUN SETUP WIZARD. detectSetupState()
|
||||
# (lib/setup/state.ts) returns "env-managed" as soon as this variable is
|
||||
# non-empty, and in that state /setup is not served at all. That is what
|
||||
# makes this chart declarative instead of "install, then click through a
|
||||
# wizard whose result lands on a volume".
|
||||
#
|
||||
# An in-cluster stalwart release is reached at its http Service, e.g.
|
||||
# http://<stalwart-release>-stalwart-<group>-http.<namespace>.svc.cluster.local:8080
|
||||
# A published one is reached at its public https URL. Either works - this is
|
||||
# server-to-server, the browser never sees it.
|
||||
#
|
||||
# THE TRAP, AND IT IS THE ONE THE UPSTREAM DOCUMENTATION ACTUALLY WARNS ABOUT:
|
||||
# a server URL saved in the admin dashboard WINS over this value (point 1 at
|
||||
# the top). If the dashboard has ever been used to set it, changing this value
|
||||
# and running `helm upgrade` moves nothing, succeeds, and says nothing. Read
|
||||
# the effective value back off the running app rather than off the chart:
|
||||
# kubectl -n <ns> exec <pod> -- cat /app/data/admin/config.json
|
||||
# An absent jmapServerUrl key in that file means this value is in force.
|
||||
jmapServerUrl: null
|
||||
|
||||
# The product name in the UI and the PWA. null leaves upstream's "Webmail".
|
||||
appName: null
|
||||
|
||||
# STALWART_FEATURES - the Stalwart-specific parts of the UI (password change,
|
||||
# Sieve rule management). True, which is upstream's default and is the point
|
||||
# of pairing this chart with stalwart at all.
|
||||
#
|
||||
# Note what it does NOT gate: the server-side credential-bearing passthrough
|
||||
# at /api/account/stalwart/jmap has its own switch upstream
|
||||
# (STALWART_JMAP_PASSTHROUGH_ENABLED, default true). This chart does not render
|
||||
# it; an installation that wants the UI features but not the passthrough sets
|
||||
# it through bulwark.env and should read the precedence note on that value
|
||||
# first.
|
||||
stalwartFeatures: true
|
||||
|
||||
# WHO MAY OPEN THE ADMIN DASHBOARD, and this value matters more here than its
|
||||
# name suggests, because after bulwark.oauth.only the dashboard is the ONLY
|
||||
# door back into a broken login path.
|
||||
#
|
||||
# Upstream's three modes (STALWART_ADMIN_ACCESS):
|
||||
# auto a Stalwart admin sees the shield and is signed into /admin
|
||||
# WITHOUT the Bulwark admin password. Upstream's default and the
|
||||
# legacy behaviour.
|
||||
# password a Stalwart admin sees the shield but must enter the Bulwark
|
||||
# admin password like everybody else.
|
||||
# off Stalwart admin status is ignored; /admin is reachable only via
|
||||
# /admin/login with the Bulwark admin password.
|
||||
#
|
||||
# "auto", i.e. upstream's default, is kept DELIBERATELY and the consequence is
|
||||
# stated rather than left to be discovered: with it, anybody who is an admin
|
||||
# on the stalwart side can write config overrides here - and an override is
|
||||
# exactly the thing that can disconnect this chart from its own values
|
||||
# (point 1) or lock the login path (bulwark.oauth.only). That is usually the
|
||||
# same person, which is why it is not changed; if it is not, "password"
|
||||
# narrows the door to whoever holds the vault item, and "off" also removes the
|
||||
# shield from the UI. Both require admin-password to be configured - which
|
||||
# this chart always does - or the dashboard would become unreachable.
|
||||
stalwartAdminAccess: auto
|
||||
|
||||
# SETTINGS_SYNC_ENABLED - per-account user preferences follow the user between
|
||||
# browsers instead of living in one browser's local storage.
|
||||
#
|
||||
# ON. The consequence is that /app/data/settings on the PVC carries USER DATA,
|
||||
# encrypted with the session secret - which is where the two-part backup
|
||||
# statement at secrets.appSecretRef comes from. It also means the secret is
|
||||
# mandatory rather than recommended: without it this switch is ignored,
|
||||
# silently.
|
||||
settingsSync: true
|
||||
|
||||
# ADMIN_CONFIG_READONLY - refuse every write to the admin CONFIG directory at
|
||||
# the application layer, which makes the values in this file authoritative and
|
||||
# closes the precedence trap of point 1 for good: with no override able to
|
||||
# appear, bulwark.oauth.only (and every other value here) always takes effect.
|
||||
#
|
||||
# OFF BY DEFAULT, AND THE PRICE IS WHY. It is not one cost but three, and the
|
||||
# third was measured rather than inferred - it is bigger than the plan for
|
||||
# this chart assumed:
|
||||
#
|
||||
# (a) POLICY, THEMES AND BRANDING FREEZE TOO. assertWritable
|
||||
# (lib/admin/paths.ts) guards setPolicy, markSetupComplete, the plugin
|
||||
# and theme registries and the branding upload - not just the config
|
||||
# override. So the settings policy becomes unchangeable, and with it the
|
||||
# ONLY way to point Web Push at your own relay (see bulwark.pushRelay).
|
||||
# (b) AN OVERRIDE THAT ALREADY EXISTS IS FROZEN IN PLACE, not removed.
|
||||
# removeAdminOverride is guarded by the same assertWritable. Turning this
|
||||
# on while config.json already carries `oauthOnly: true` therefore makes
|
||||
# that lockout permanent and takes away the documented repair at the same
|
||||
# time. If you are turning this on, read config.json first.
|
||||
# (c) ON A FRESH INSTALL IT TAKES THE ADMIN DASHBOARD AWAY ENTIRELY - i.e.
|
||||
# exactly the door this chart keeps a password for. initAdminPassword
|
||||
# persists the hashed ADMIN_PASSWORD by calling writeConfigData, which
|
||||
# begins with assertWritable('save admin password'). With no admin.json
|
||||
# on the volume yet, that throws: the startup hook logs "Admin dashboard
|
||||
# init skipped", the retry inside /api/admin/auth throws again, and the
|
||||
# login endpoint answers HTTP 500. The pod is Ready and serving mail the
|
||||
# whole time, so nothing reports it.
|
||||
#
|
||||
# THE ONLY SAFE SEQUENCE, which is also upstream's own framing ("Operators set
|
||||
# ADMIN_CONFIG_READONLY=true after running the setup wizard"):
|
||||
# 1. install with this false, let the pod start once, so admin.json exists
|
||||
# 2. set every policy you want (push relay included) - after step 3 you
|
||||
# cannot
|
||||
# 3. check that config.json carries no override you still need removed
|
||||
# 4. then helm upgrade with this true
|
||||
# The chart cannot verify step 1 for you: it has no way to know what is on the
|
||||
# volume. NOTES.txt repeats the sequence.
|
||||
adminConfigReadOnly: false
|
||||
|
||||
oauth:
|
||||
# OIDC SIGN-IN against the identity provider (keycloak, #83). ON.
|
||||
enabled: true
|
||||
|
||||
# OAUTH_ONLY - hide the password form entirely, so the identity provider is
|
||||
# the only way for a USER to sign in. ON, and this is the chart's riskiest
|
||||
# default, taken deliberately with the way back out written down.
|
||||
#
|
||||
# THE RISK: if the provider is down or the OIDC configuration is wrong,
|
||||
# nobody reaches their mail, and there is no password form to fall back to.
|
||||
#
|
||||
# THE WAY BACK, AND IT IS NOT WHAT IT LOOKS LIKE. `helm upgrade` with this
|
||||
# set to false is NOT reliably the repair, and believing it was would be the
|
||||
# trap of point 1 sitting inside the rescue instruction itself: oauthOnly is
|
||||
# an ordinary key of CONFIG_ENV_MAP, every consumer reads it through
|
||||
# configManager.get (app/api/auth/session/route.ts,
|
||||
# app/api/auth/verify/route.ts, app/api/config/route.ts - none of them reads
|
||||
# process.env), and the admin dashboard's OAuth setup writes
|
||||
# `oauthOnly: true` into config.json when it is used. Once that override is
|
||||
# on the PVC, this value is ignored and the upgrade reports success.
|
||||
#
|
||||
# WHAT REALLY WORKS, and it does not depend on the identity provider:
|
||||
# OPEN THE ADMIN DASHBOARD (its own password, from the vault item) AND
|
||||
# REMOVE THE OVERRIDE - the dashboard's revert, i.e. DELETE on
|
||||
# /api/admin/config, which calls configManager.removeAdminOverride.
|
||||
# After that the chart's value applies again. If the dashboard itself is
|
||||
# unreachable, the remaining repair is editing or deleting config.json on
|
||||
# the PVC directly. Both are in NOTES.txt.
|
||||
#
|
||||
# AND THE PRE-INSTALL STEP THIS DEFAULT MAKES MANDATORY: the OIDC client
|
||||
# must be registered with the provider BEFORE the first install, not after.
|
||||
# With oauth.only true and no working client, the very first sign-in is
|
||||
# impossible. NOTES.txt prints the redirect URI to register.
|
||||
only: true
|
||||
|
||||
# OAUTH_CLIENT_ID. Required while oauth.enabled is true.
|
||||
clientId: null
|
||||
|
||||
# OAUTH_ISSUER_URL - the issuer, from which Bulwark fetches the discovery
|
||||
# document. Required while oauth.enabled is true. For keycloak (#83) this is
|
||||
# https://<keycloak-host>/realms/<realm>
|
||||
#
|
||||
# It must be reachable BY THE POD and the endpoints it advertises must share
|
||||
# the issuer's origin or be public: getDiscoveryValidator
|
||||
# (lib/oauth/token-exchange.ts) rejects a discovered endpoint on a private
|
||||
# address from a different origin unless OAUTH_ALLOW_PRIVATE_ENDPOINTS is
|
||||
# set. The chart does not render that escape hatch - a split-horizon setup
|
||||
# that needs it can add it through bulwark.env.
|
||||
issuerUrl: null
|
||||
|
||||
# OAUTH_SCOPES - replaces the scope list Bulwark would otherwise request.
|
||||
# null leaves upstream's default, which is the right starting point; an
|
||||
# incomplete list here is a login that succeeds and then has no identity to
|
||||
# work with.
|
||||
scopes: null
|
||||
|
||||
# OAUTH_END_SESSION - signing out of Bulwark also ends the provider's
|
||||
# session through its end_session_endpoint. True, upstream's default: on a
|
||||
# shared browser, a sign-out that leaves the provider signed in means the
|
||||
# next click is straight back in. Set it false where the provider is shared
|
||||
# with applications that should stay signed in.
|
||||
endSession: true
|
||||
|
||||
# WEB PUSH RELAY - read this before writing it down as a disclosure, because
|
||||
# the obvious reading of the build-time variable is wrong and was corrected
|
||||
# against the source.
|
||||
#
|
||||
# NEXT_PUBLIC_PUSH_RELAY_URL is baked into the bundle (point 4 at the top), so
|
||||
# this chart cannot move the BUILT-IN entry, which is upstream's hosted relay
|
||||
# at https://notifications.relay.bulwarkmail.org. It would be wrong to stop
|
||||
# there and declare Web Push "not changeable with the published image": that
|
||||
# variable only sets the FALLBACK. lib/push-relays.ts resolves the effective
|
||||
# relay as `resolveDefaultRelayUrl(policy)`, which takes policy.pushRelayUrl
|
||||
# whenever it is a valid URL and uses the built-in one only otherwise; with
|
||||
# policy.pushRelayUrlLocked an operator can additionally pin every user to it.
|
||||
#
|
||||
# So the correct, two-part statement, and both halves matter:
|
||||
# - WITHOUT further action, Web Push for this release goes through the
|
||||
# VENDOR'S relay. For a mail client that is a fact worth knowing, and it
|
||||
# stays in the users' relay list either way, because the list is built
|
||||
# from the bundle.
|
||||
# - A SELF-HOSTED RELAY CAN BE MADE THE EFFECTIVE DEFAULT AND MANDATORY -
|
||||
# but through policy.json, i.e. the ADMIN DASHBOARD, not through a chart
|
||||
# value. There is deliberately no value here: pushRelayUrl lives in
|
||||
# SettingsPolicy and has no entry in CONFIG_ENV_MAP, so no environment
|
||||
# variable reaches it and a chart value would be a switch wired to
|
||||
# nothing.
|
||||
#
|
||||
# AND THE INTERACTION TO KEEP IN MIND: bulwark.adminConfigReadOnly freezes
|
||||
# setPolicy, so turning that on before setting the relay makes this change
|
||||
# impossible afterwards. Order matters - see the sequence at that value.
|
||||
#
|
||||
# THERE IS DELIBERATELY NO VALUE UNDER THIS COMMENT. A `pushRelayUrl:` key
|
||||
# here would render nothing and change nothing, which is the exact defect
|
||||
# guideline 12 exists to remove - a key that looks like configuration and does
|
||||
# nothing. The knowledge belongs in the file an operator reads; the switch
|
||||
# belongs where it actually is, in the dashboard.
|
||||
|
||||
# BULWARK_TELEMETRY - upstream's anonymous telemetry.
|
||||
#
|
||||
# FALSE, AND THAT IS NOT MERELY RESTATING THE IMAGE'S DEFAULT, which is the
|
||||
# reason this value exists at all. Measured in lib/telemetry/state.ts: consent
|
||||
# defaults to 'off' and telemetry really is opt-in (the comment in
|
||||
# instrumentation.node.ts calling it "on by default" is wrong - DEFAULTS carries
|
||||
# `consent: 'off'`). What rendering the variable buys is the LOCK:
|
||||
# effectiveConsent() takes the environment over the state file, and upstream
|
||||
# blocks the admin UI from changing it while the override is active ("UI changes
|
||||
# are blocked when env override is active"). So this is one of the very few
|
||||
# settings in this chart where the chart beats the volume rather than the other
|
||||
# way round - see the exception list in point 1 at the top of this file.
|
||||
#
|
||||
# Setting it true opts in; /app/data/telemetry on the PVC then holds the
|
||||
# consent record and the instance id, which is why that directory is inside the
|
||||
# single volume rather than an emptyDir.
|
||||
telemetry: false
|
||||
|
||||
# BULWARK_UPDATE_CHECK - an hourly version check against
|
||||
# version.telemetry.bulwarkmail.org.
|
||||
#
|
||||
# FALSE, AND HERE THE DEFAULT REALLY IS THE OTHER WAY: unlike telemetry, this
|
||||
# check is ON unless switched off (lib/version-check/state.ts, disabledByEnv()
|
||||
# recognizes only off/false/0/no - an absent variable means enabled). So this
|
||||
# value does change behaviour.
|
||||
#
|
||||
# Off for two reasons: a mail client should not phone home from a private
|
||||
# cluster unless somebody chose it, and Renovate already watches the image tag
|
||||
# (renovate.json5) - so the check would answer outbound a question that is
|
||||
# already answered inbound, and answer it to a third party.
|
||||
updateCheck: false
|
||||
|
||||
# LOG_LEVEL - error, warn, info or debug.
|
||||
logLevel: info
|
||||
|
||||
# LOG_FORMAT - text or json. JSON here, against upstream's default of text,
|
||||
# because the logs go to a cluster log collector rather than to a terminal.
|
||||
logFormat: json
|
||||
|
||||
# The HTTP port. 3000, which is what the image binds (ENV PORT=3000,
|
||||
# EXPOSE 3000) and what HOSTNAME=0.0.0.0 makes reachable. Both the Service and
|
||||
# all three probes address this port BY NAME ("http"), so changing the number
|
||||
# here is safe and renaming the port is not.
|
||||
port: 3000
|
||||
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a CPU
|
||||
# limit is only set when configured explicitly - CPU is compressible, so
|
||||
# leaving it unlimited throttles instead of OOM-killing.
|
||||
#
|
||||
# 256Mi x 3 = 768Mi. A Next.js standalone server serving a SPA and proxying
|
||||
# JMAP holds no mail and caches no mailbox, so the footprint is the Node
|
||||
# heap plus the image-optimization cache - not a function of how much mail
|
||||
# the users have.
|
||||
#
|
||||
# THE MEMORY LIMIT IS LOAD-BEARING IN ONE DIRECTION, though: /api/health
|
||||
# reports `degraded` once V8 heap usage crosses its warning threshold, and
|
||||
# the heap limit is derived from the container limit. That report is not
|
||||
# visible to the probes (see bulwark.readinessProbe), so a limit set too low
|
||||
# produces a pod that is permanently degraded, permanently Ready, and says
|
||||
# so only to an authenticated caller asking for details.
|
||||
#
|
||||
# The ephemeral-storage request covers the two emptyDirs (/tmp and the
|
||||
# Next.js cache) plus container logs. It does NOT cover /app/data, which is
|
||||
# the PVC - see storage.amount.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
# ALL THREE PROBES ARE AN HTTP GET ON /api/health, and the limit of that is
|
||||
# written at the readiness probe below rather than left for somebody to assume.
|
||||
#
|
||||
# A NOTE ON GET VERSUS HEAD, so nobody "fixes" this back. Bulwark offers a HEAD
|
||||
# handler on this path and it would be the cheaper call - but a KUBERNETES PROBE
|
||||
# CANNOT SEND HEAD. The API type is HTTPGetAction, it has no method field, and
|
||||
# the kubelet always issues a GET. The only way to send a HEAD is an exec probe
|
||||
# calling an HTTP client, and this image has neither curl nor wget (npm and npx
|
||||
# are removed from the runtime layer), so it would mean starting a Node process
|
||||
# every few seconds instead of one cheap HTTP request. The GET handler does
|
||||
# gather diagnostics, which makes it marginally MORE capable than HEAD, not
|
||||
# less: it can answer 503 when the diagnostics themselves throw.
|
||||
#
|
||||
# LIVENESS: the process is alive, accepting HTTP, its event loop is free and
|
||||
# Next.js can route. An OOM kill, a crash and a wedged event loop all fail it.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# READINESS: the same endpoint, and HERE IS THE LIMIT, stated at the value
|
||||
# because a probe path is not a promise (README guideline 12.3; #218 and
|
||||
# Gitea #83 are the two times this repo paid for assuming otherwise).
|
||||
#
|
||||
# WHAT THIS PROBE CARRIES: the Node process is alive, the HTTP server accepts,
|
||||
# the event loop is not blocked, Next.js routes. That is real and it is not
|
||||
# nothing.
|
||||
#
|
||||
# WHAT IT DOES NOT CARRY, measured in app/api/health/route.ts at tag 1.12.0:
|
||||
# - THE REPORTED STATE. The success path returns a hard `status: 200`
|
||||
# regardless of what it computed, and for an unauthenticated caller the
|
||||
# body is hard-coded to "healthy" as well
|
||||
# (`status: detailed ? status : 'healthy'`); "degraded" appears only with
|
||||
# ?detailed=true AND a caller that passes canSeeInstanceDetails. Upstream
|
||||
# tests precisely this - lib/__tests__/health-route.test.ts expects HTTP
|
||||
# 200 while payload.status is "degraded". A probe is unauthenticated and
|
||||
# asks for no details, so it sees 200 and "healthy" always. The only 503
|
||||
# this endpoint can produce comes from its catch block, i.e. when
|
||||
# process.memoryUsage() or v8.getHeapStatistics() itself throws.
|
||||
# - ANYTHING ABOUT STALWART. Bulwark holds no mail and this endpoint asks the
|
||||
# JMAP server nothing. WITH THE MAIL SERVER COMPLETELY GONE, THIS PROBE IS
|
||||
# GREEN AND THE POD STAYS Ready. Monitor the stalwart release for that;
|
||||
# nothing here can.
|
||||
#
|
||||
# AND WHY IT IS STILL THIS ENDPOINT: there is no better one.
|
||||
# /api/setup/test-jmap really would test the connection, but it lives behind
|
||||
# the bootstrap setup token and this chart disables the wizard, so it is
|
||||
# unreachable. Pointing readiness at "/" instead would only add Next.js
|
||||
# rendering to the same set of guarantees, and would start failing on an
|
||||
# unauthenticated redirect rather than on anything about the mail path.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# STARTUP: gates the other two, which is why neither carries an
|
||||
# initialDelaySeconds.
|
||||
#
|
||||
# BUDGET = periodSeconds x failureThreshold = 5 x 24 = 120s. What that has to
|
||||
# cover is not the Next.js server, which starts in about a second: it is
|
||||
# attaching and mounting the network volume, and the instrumentation hook that
|
||||
# runs before the first request is served (instrumentation.node.ts migrates
|
||||
# the legacy admin layout, loads config.json and policy.json off the volume,
|
||||
# and on a FIRST install hashes ADMIN_PASSWORD with scrypt and writes
|
||||
# admin.json - the scrypt hash being the one deliberately slow step).
|
||||
#
|
||||
# NOT MEASURED AGAINST A RUNNING POD AT THE TIME OF WRITING, and said so
|
||||
# rather than presented as calibrated: the budget is derived from what the
|
||||
# start does, with head room, and the cost of head room is nothing because a
|
||||
# pod that IS healthy becomes Ready on the first probe either way.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 24
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
# uid/gid 1001, NOT this repo's 20000, and that is the image's own user
|
||||
# rather than a free choice: the Dockerfile creates
|
||||
# addgroup --system --gid 1001 nodejs
|
||||
# adduser --system --uid 1001 nextjs
|
||||
# and copies the application tree plus /app/data with --chown=nextjs:nodejs.
|
||||
# 1001 therefore OWNS everything the process reads, and the four data
|
||||
# subdirectories are chowned to it in the image. Read off the Dockerfile at
|
||||
# tag 1.12.0, not guessed.
|
||||
#
|
||||
# fsGroupChangePolicy: Always, the repo standard, and here it is the right
|
||||
# one rather than the default one: /app/data holds a handful of JSON files,
|
||||
# an audit log and a small settings tree, so the recursive chown is
|
||||
# cheap - unlike garage's block store or satisfactory's game files, where
|
||||
# it is the reason both deviate to OnRootMismatch. It also matters on the
|
||||
# FIRST start, because a freshly provisioned volume arrives owned by root
|
||||
# and Bulwark has to create its four subdirectories in it.
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: Always
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
# TRUE, with two emptyDirs carrying the writes that are not state. Which
|
||||
# paths those are, and how the set was established, is at
|
||||
# bulwark.writableCache below - it is the one part of this chart that was
|
||||
# derived from the source and then PROVEN by the install-test rather than
|
||||
# the other way round.
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# EMPTY, and it can stay empty: the process binds 3000, which is above
|
||||
# 1024, and the entrypoint is `node server.js` with no file
|
||||
# capabilities - so there is no execve-time capability check like
|
||||
# stalwart's.
|
||||
add: []
|
||||
|
||||
# THE WRITABLE PATHS OUTSIDE THE VOLUME, as emptyDirs, because
|
||||
# readOnlyRootFilesystem is true and Next.js writes in two places that are not
|
||||
# application state.
|
||||
#
|
||||
# HOW THIS SET WAS ESTABLISHED, and the honest order of events: it was DERIVED
|
||||
# from next.config.ts at tag 1.12.0 - `output: "standalone"`, and
|
||||
# `images: { unoptimized: true }` applies only in the isLite branch, so the
|
||||
# published image has image optimization ACTIVE and writes its cache under
|
||||
# /app/.next/cache - and it was then PROVEN by the install-test, which starts
|
||||
# the real image with this exact hardening and requires the pod to become
|
||||
# Ready. A missing writable path here is not a subtle defect: it is an EACCES
|
||||
# or EROFS at the first write.
|
||||
#
|
||||
# /tmp the conventional scratch path. Node, Next.js and the
|
||||
# image-optimization pipeline all use it.
|
||||
# /app/.next/cache Next.js's own cache directory in standalone mode: the
|
||||
# optimized-image cache and the fetch cache. It is a
|
||||
# SUBDIRECTORY of .next, which is why it can be an
|
||||
# emptyDir at all - /app/.next/static holds the build
|
||||
# output and must stay readable, and mounting the cache
|
||||
# path does not shadow it.
|
||||
#
|
||||
# Both are CACHES and neither is state: losing them on a restart costs one
|
||||
# round of image re-optimization. Do not "simplify" either one onto the PVC -
|
||||
# a cache on a ReadWriteOnce network volume is slower than regenerating it,
|
||||
# and it would grow the only volume that has to be backed up.
|
||||
writableCache:
|
||||
# Size limits on both, so a runaway cache fills an emptyDir instead of the
|
||||
# node's disk. These are the sizeLimit of each emptyDir; the pod's
|
||||
# ephemeral-storage request/limit (bulwark.resources) is the other half of
|
||||
# the same accounting and the two should move together.
|
||||
tmpSizeLimit: 256Mi
|
||||
nextCacheSizeLimit: 512Mi
|
||||
|
||||
# Additive environment entries for the Bulwark container, rendered through tpl
|
||||
# and supporting the value, secretKeyRef and configMapKeyRef forms.
|
||||
#
|
||||
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
||||
# does not replace it. At install time both reach the pod and the later one
|
||||
# wins; on an EXISTING release the pair collapses to the chart's own value,
|
||||
# because Kubernetes merges env by name - and helm upgrade still reports
|
||||
# success. So do not use this to override JMAP_SERVER_URL, OAUTH_ONLY or
|
||||
# SESSION_SECRET_FILE; change the values that produce them.
|
||||
#
|
||||
# AND THE SECOND, SHARPER CAVEAT, SPECIFIC TO THIS APPLICATION: an environment
|
||||
# variable is only the MIDDLE of three layers (point 1 at the top of this
|
||||
# file). Anything set here is a default that an admin override in config.json
|
||||
# on the PVC may already be beating, silently. An entry here is therefore not
|
||||
# a guarantee - unless bulwark.adminConfigReadOnly is on, and that has its own
|
||||
# price.
|
||||
#
|
||||
# Genuinely useful here, all of them CONFIG_ENV_MAP keys this chart does not
|
||||
# render: APP_DESCRIPTION, LOGIN_COMPANY_NAME and the other LOGIN_* branding
|
||||
# variables, COOKIE_SAME_SITE, STALWART_JMAP_PASSTHROUGH_ENABLED,
|
||||
# OAUTH_EXTRA_SCOPES, WOPI_CLIENT_URL.
|
||||
env: []
|
||||
|
||||
storage:
|
||||
# THE ONE PVC, mounted at /app/data, and "one" is the decision rather than the
|
||||
# obvious shape - Bulwark keeps FOUR directories under it and they could each
|
||||
# have their own volume:
|
||||
#
|
||||
# /app/data/admin config.json, policy.json, admin.json (the password
|
||||
# hash), plugins, themes, branding uploads
|
||||
# /app/data/admin-state admin-state.json (login timestamps), audit.log, the
|
||||
# bootstrap setup token
|
||||
# /app/data/settings the encrypted per-account user settings
|
||||
# /app/data/telemetry consent and instance id
|
||||
#
|
||||
# ONE VOLUME, BECAUSE A SNAPSHOT IS ATOMIC PER VOLUME AND NEVER ACROSS THEM -
|
||||
# the lesson garage (#81) was built on. Split over four, a backup could
|
||||
# capture an audit log that records logins the configuration does not know
|
||||
# about yet, or settings encrypted against a secret the config has since been
|
||||
# repointed from. One volume keeps configuration, runtime state, audit trail
|
||||
# and user settings consistent with each other, and there is no performance
|
||||
# argument on the other side: this is a few hundred kilobytes of JSON plus an
|
||||
# append-only log.
|
||||
#
|
||||
# Bulwark creates all four subdirectories itself on start (ensureConfigDir /
|
||||
# ensureStateDir, both mkdir -p), which it has to, because the image's own
|
||||
# mkdir ran in a layer that this mount covers.
|
||||
#
|
||||
# THE PVC IS DELIBERATELY NOT null-SAFE: an erased size yields an invalid PVC
|
||||
# that is rejected at apply time, which is loud. A chart-side fallback would
|
||||
# silently size somebody's volume - and on an existing release it could not
|
||||
# help anyway, because a PVC cannot shrink. Same reasoning as garage's storage
|
||||
# block and satisfactory's documented exception (Gitea #39).
|
||||
#
|
||||
# IT IS ALSO NEVER RENAMED: a renamed PVC makes Kubernetes provision a new,
|
||||
# empty volume and orphans the data - including admin.json, which is the only
|
||||
# copy of the admin password hash, and the audit log, which by its nature
|
||||
# cannot be reconstructed.
|
||||
#
|
||||
# 1Gi is generous for the JSON files and the settings tree; what actually
|
||||
# grows is audit.log, and it grows with admin activity rather than with mail
|
||||
# volume. There is no rotation for it in the application, so this is the value
|
||||
# to watch on a long-lived release.
|
||||
amount: 1Gi
|
||||
|
||||
# null uses the cluster's default StorageClass. ReadWriteOnce is enough and is
|
||||
# what the single replica needs; see bulwark.replicas for why more than one is
|
||||
# not on offer.
|
||||
storageClass: null
|
||||
|
||||
ingress:
|
||||
# ON by default: a webmail client nobody can reach is not a webmail client.
|
||||
enabled: true
|
||||
|
||||
# The public hostname. REQUIRED, AND REQUIRED EVEN WITH ingress.enabled:
|
||||
# false - this is guideline 12.1, checked in both directions for this chart.
|
||||
# The value is read a SECOND time outside the Ingress template: NOTES.txt
|
||||
# derives the OIDC redirect URI from it, and that URI is what has to be
|
||||
# registered with the identity provider before the first install. Gating it
|
||||
# away would produce a release that renders cleanly and whose operator is told
|
||||
# to register `https:///auth/callback`.
|
||||
#
|
||||
# AND A THIRD TIME SINCE Gitea #114: the HTTPRoute reads it as its hostname
|
||||
# when httpRoute.hostnames is empty, with a required() of its own. So this
|
||||
# value is mandatory in GATEWAY mode as well, and only a release with both
|
||||
# modes off can do without it.
|
||||
#
|
||||
# BULWARK IS SERVED AT THE ROOT OF THIS HOST AND CANNOT BE SERVED UNDER A
|
||||
# PATH. NEXT_PUBLIC_BASE_PATH is compiled into the bundle (point 4 at the top
|
||||
# of this file), so a sub-path deployment would need a rebuilt image. The
|
||||
# Ingress rule is therefore "/" and there is no value to change it - a
|
||||
# rewrite-target annotation would not help either, because the bundle's own
|
||||
# asset URLs are built for the root.
|
||||
domain: null
|
||||
|
||||
# cert-manager cluster issuer. Required while the Ingress is enabled.
|
||||
clusterIssuer: null
|
||||
className: nginx
|
||||
|
||||
# Extra annotations, merged with the cert-manager annotation the chart always
|
||||
# sets.
|
||||
#
|
||||
# ONE WORTH KNOWING ABOUT FOR THIS APPLICATION: file attachments and the Files
|
||||
# view upload through this Ingress, and nginx's default
|
||||
# proxy-body-size (1m) will reject anything larger with a 413 that looks like
|
||||
# an application fault. Set
|
||||
# nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
# (or whatever the mail server accepts) to match stalwart's own limit. The
|
||||
# chart does not set it, because the right number is the mail server's and
|
||||
# this chart does not know it.
|
||||
#
|
||||
# AND THAT RECOMMENDATION IS NOT HYPOTHETICAL - IT IS WHAT THIS INSTALLATION
|
||||
# RUNS ON. Measured on 2026-10-12: the live Ingress on
|
||||
# webmail.bekemeyer-klein.de carries exactly
|
||||
# nginx.ingress.kubernetes.io/proxy-body-size: 25m, supplied here by the
|
||||
# operator. In GATEWAY MODE this whole block is INERT - the Gateway API has
|
||||
# no annotation channel for controller behaviour, and body size has no
|
||||
# counterpart in the specification at all. Read the httpRoute block below
|
||||
# before switching modes.
|
||||
annotations: {}
|
||||
|
||||
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
|
||||
#
|
||||
# Mutually exclusive with ingress.enabled - the render aborts when both are
|
||||
# true, and the message names both value paths. The chart renders ONLY an
|
||||
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate, and no
|
||||
# implementation-specific policy object. Class and certificate sit on the
|
||||
# Gateway and its listeners and belong to the platform (cluster-config), which
|
||||
# is what keeps the Gateway implementation replaceable without touching this
|
||||
# chart again.
|
||||
#
|
||||
# ONE ROUTE, ONE RULE, AT THE ROOT. "/" is as permanent here as it is on the
|
||||
# Ingress, for the same reason: NEXT_PUBLIC_BASE_PATH is compiled into the
|
||||
# bundle, so Bulwark cannot be served under a sub-path and there is nothing for
|
||||
# a URLRewrite filter to strip. This chart renders no such filter and needs
|
||||
# none - which also means the #118 prefix/redirect assurance in
|
||||
# ci/run-httproute-match-test.sh has no precondition to meet here.
|
||||
#
|
||||
# WHAT THIS MODE COSTS, AND IT IS THE ONE ANNOTATION THIS INSTALLATION USES.
|
||||
# proxy-body-size: 25m is set on the live Ingress by the operator (see the
|
||||
# annotations block above). It is GONE in this mode and cannot be rebuilt here:
|
||||
# the Gateway API knows no body size, that is implementation policy, and
|
||||
# decision 2 of #114 says the limit is documented rather than reconstructed
|
||||
# with a policy object that would bind this chart to one implementation.
|
||||
#
|
||||
# What that looks like in practice, for THIS application: an attachment above
|
||||
# the GATEWAY's default is rejected before Bulwark sees a byte, and the 413
|
||||
# comes from the proxy. The user is composing mail with a file attached, so the
|
||||
# failure arrives at the least recoverable moment and reads like a webmail
|
||||
# fault. Set the equivalent next to the Gateway - in that implementation's own
|
||||
# object - BEFORE pointing DNS at it, and size it to what the MAIL SERVER
|
||||
# accepts (stalwart), not to what feels generous. NOTES.txt prints this in
|
||||
# Gateway mode.
|
||||
#
|
||||
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
|
||||
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
|
||||
# without them fails at install, so this is a value and not a guess.
|
||||
httpRoute:
|
||||
enabled: false
|
||||
|
||||
# The Gateway(s) the route attaches to - a platform object, usually in
|
||||
# another namespace, whose listener must allow this namespace in
|
||||
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
|
||||
# optionally namespace, sectionName, port, group, kind), passed through
|
||||
# unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
|
||||
# Empty means [ingress.domain], so the hostname has one source of truth. Set
|
||||
# explicitly only to publish under a different or an additional name - which
|
||||
# is how a migration from the Ingress runs: the HTTPRoute takes a second
|
||||
# hostname while the Ingress keeps ingress.domain.
|
||||
#
|
||||
# AND IF YOU SET IT, REGISTER THAT ORIGIN WITH THE IDENTITY PROVIDER FIRST.
|
||||
# NOTES.txt derives the OIDC redirect URI from the ACTIVE mode's hostname, so
|
||||
# a second hostname means a second redirect URI - a login that returns to an
|
||||
# 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: []
|
||||
@@ -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.0.1+upv4.0.6
|
||||
appVersion: v4.0.6
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
{{- $startup := fromYaml (include "calibre-web-automated.startupProbe" .Values.cwa.startupProbe) -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||
{{- $pod := printf "%s-0" $sfs -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
Calibre-Web Automated {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
ACCESS
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||
@@ -25,59 +20,12 @@ ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
controller before CWA ever sees it, so nothing about it reaches the app log.
|
||||
Both annotations are understood by the nginx-family controllers only, and this
|
||||
release uses ingressClassName "{{ .Values.ingress.className }}".
|
||||
{{- else if $httpRoute.enabled }}
|
||||
|
||||
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- range $hostnames }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
|
||||
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING
|
||||
else of the routing path — no Gateway, no class, no Certificate. Three things
|
||||
therefore belong to the cluster and not to this chart, and if any is missing
|
||||
the address above does not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
|
||||
up only in the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is a working attachment. An empty status means no
|
||||
controller has looked at the route at all.
|
||||
3. The TLS certificate for the hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required: the
|
||||
chart renders no Certificate, so there is no
|
||||
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
|
||||
|
||||
YOUR UPLOAD LIMIT IS NOT cwa.maxUploadFileSize ANY MORE — IT IS THE GATEWAY'S.
|
||||
cwa.maxUploadFileSize ({{ .Values.cwa.maxUploadFileSize }}) is NOT ENFORCED in this mode. The chart
|
||||
renders it into nginx.ingress.kubernetes.io/proxy-body-size, and the Gateway
|
||||
API has NO counterpart — it knows no body size at all, that is
|
||||
implementation-specific policy — so this chart deliberately renders no policy
|
||||
object, which would bind it to one implementation. Whatever limit the Gateway
|
||||
enforces is the limit your uploads get, and this is an e-book library: a book
|
||||
above the Gateway's default is rejected before CWA sees a byte, the user gets
|
||||
the proxy's error instead of a message from the application, and nothing says
|
||||
a chart value was ignored. Set the limit at the Gateway, or through its
|
||||
implementation's own policy object next to the Gateway — not here.
|
||||
|
||||
ingress.annotations is inert in this mode as well, for the same reason: the
|
||||
Gateway API has no annotation channel.
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so this release is not
|
||||
published: no Ingress, no HTTPRoute and no certificate. Neither
|
||||
ingress.domain nor ingress.clusterIssuer is needed — this chart reads
|
||||
ingress.domain in the two routing templates and nowhere else, and with both
|
||||
of them off nothing reads it. The app is reachable
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate.
|
||||
Neither ingress.domain nor ingress.clusterIssuer is needed: this chart reads
|
||||
ingress.domain in the Ingress template and nowhere else. The app is reachable
|
||||
inside the cluster as {{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8083:80
|
||||
@@ -91,9 +39,7 @@ SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}cwa.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing
|
||||
answers until you set it back and upgrade again. Both PVCs, the Secret and the
|
||||
Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so no data is lost and the certificate
|
||||
is not re-issued.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so no data is lost — the route then
|
||||
resolves to a Service with no endpoints and the Gateway answers its own
|
||||
error.{{ else }} stay in place, so no data is lost.{{ end }}
|
||||
is not re-issued.{{ else }} stay in place, so no data is lost.{{ end }}
|
||||
{{ end }}
|
||||
FIRST LOGIN — THE DEFAULT PASSWORD IS PUBLIC
|
||||
Username "admin", password "admin123". Those are the upstream defaults, they
|
||||
@@ -217,51 +163,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,101 +0,0 @@
|
||||
{{/*
|
||||
The Gateway-API alternative to calibre-web-automated.ingress.yaml (Gitea #114).
|
||||
Form and reasoning: template-chart/templates/xxx.httproute.yaml and README,
|
||||
"Ingress and Gateway API".
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, and no implementation-specific policy object - that subset looks
|
||||
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
|
||||
the whole reason the cluster's implementation can be swapped without touching
|
||||
this chart. Class and certificate therefore belong to the PLATFORM, on the
|
||||
Gateway and its listeners.
|
||||
|
||||
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
|
||||
in this mode - and it is not demanded either: its only required() sits inside
|
||||
calibre-web-automated.ingress.yaml, behind ingress.enabled.
|
||||
|
||||
THE UPLOAD LIMIT IS THE ONE THING THIS MODE LOSES, AND IT IS NOT REBUILT
|
||||
|
||||
cwa.maxUploadFileSize is rendered into nginx.ingress.kubernetes.io/proxy-body-size
|
||||
and nginx.org/client-max-body-size by the Ingress template. The Gateway API has
|
||||
NO counterpart: it knows no body size at all, because a request-body limit is
|
||||
implementation-specific policy and lives in an implementation's own object
|
||||
(ClientSettingsPolicy on nginx-gateway-fabric, ClientTrafficPolicy on Istio,
|
||||
and so on). Rendering one here would bind this chart to one implementation and
|
||||
to a CRD whose presence it cannot check, so the limit is DOCUMENTED instead -
|
||||
NOTES.txt says in this mode that the value is not enforced and names where to
|
||||
set it. The price is named and deliberate: whoever overlooks that output gets
|
||||
the Gateway's default limit, and an oversized book fails as a proxy error
|
||||
rather than as a message from CWA. This chart is an e-book library, so the
|
||||
value is not decoration - a book above the Gateway's default is rejected, and
|
||||
the rejection carries no hint that a chart value was ignored.
|
||||
|
||||
ingress.domain, AND WHY THIS FILE CHANGES WHAT CAN BE CLAIMED ABOUT IT
|
||||
|
||||
Checked for this chart rather than copied: ingress.domain is read in the
|
||||
Ingress template and - until this file existed - NOWHERE else. No environment
|
||||
variable, no probe Host header, no URL the app builds itself; CWA is told its
|
||||
address by nothing. That is why values.yaml could say that switching the
|
||||
Ingress off makes domain unnecessary along with clusterIssuer.
|
||||
|
||||
This file is now a second reader (when httpRoute.hostnames is empty), so that
|
||||
sentence holds only while BOTH modes are off. It is corrected in values.yaml
|
||||
and in values.schema.json rather than left standing. In Gateway mode with
|
||||
hostnames empty, domain is required again - by the required() below.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block this chart dereferences (#99): an override
|
||||
written without children - "httpRoute:" with the keys below it deleted - is
|
||||
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
|
||||
pointer. An empty block means "chart defaults apply", and all three defaults
|
||||
are empty or false, so the whole block collapses to "no HTTPRoute".
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
|
||||
even though no Ingress is rendered in this mode - the value names the public
|
||||
hostname of this release, which both modes need; it is not an Ingress switch.
|
||||
Nothing inside the pod follows it, so a hostname set explicitly here needs no
|
||||
second value changed with it - which is what makes a migration over a second
|
||||
hostname cheap in THIS chart and expensive in others.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
|
||||
# backend Service - CWA serves its whole UI, including the OPDS feed and
|
||||
# the ingest views, from one process on one port, so a single path covers
|
||||
# it. The port is the Service's port NUMBER and not its name ("http"):
|
||||
# backendRefs takes a PortNumber, so unlike the Ingress this reference
|
||||
# cannot be made by name and the 80 below has to track
|
||||
# calibre-web-automated.service.yaml by hand.
|
||||
# ci/run-httproute-match-test.sh is what holds that pair - it fails when a
|
||||
# backendRef names a Service, or a port, the same render does not create.
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port: 80
|
||||
{{- 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 }}
|
||||
@@ -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"
|
||||
},
|
||||
@@ -93,7 +42,7 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"maxUploadFileSize": {
|
||||
"description": "Rendered into the ingress-nginx proxy-body-size and nginx.org/client-max-body-size annotations, so it is enforced by the ingress controller and by nothing inside CWA. It does nothing with ingress.enabled: false, and it is NOT ENFORCED in Gateway mode either (Gitea #114): the Gateway API has no body-size field, the effective limit is the Gateway's, and this chart deliberately renders no implementation-specific policy object to set it. NOTES.txt says so in that mode.",
|
||||
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"livenessProbe": {
|
||||
@@ -121,7 +70,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Nothing inside the pod reads ingress.domain, so switching the Ingress off makes both domain and clusterIssuer unnecessary - but only while the Gateway mode is off as well: the HTTPRoute is a second reader of domain while httpRoute.hostnames is empty.",
|
||||
"description": "Gates the whole Ingress template (issue #93). ingress.domain is read nowhere else in this chart, so switching the Ingress off makes both domain and clusterIssuer unnecessary.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -134,42 +83,13 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead - including the upload cap the chart itself sets from cwa.maxUploadFileSize.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. cwa.maxUploadFileSize is not enforced in this mode.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/parentRef"
|
||||
}
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Nothing inside the pod follows the hostname in this chart, so a name set here needs no second value changed with it.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
|
||||
@@ -23,15 +23,6 @@ cwa:
|
||||
storage: 10Gi
|
||||
storageClass: longhorn
|
||||
timezone: "UTC"
|
||||
# The upload cap, and it lives entirely in the INGRESS: the chart renders it
|
||||
# into nginx.ingress.kubernetes.io/proxy-body-size and
|
||||
# nginx.org/client-max-body-size, so it is enforced by the nginx-family
|
||||
# controllers and by nothing inside CWA. Consequences worth knowing before
|
||||
# trusting it: with ingress.enabled: false it does nothing at all, and in
|
||||
# Gateway mode (httpRoute.enabled) it is NOT ENFORCED either - the Gateway
|
||||
# API has no body-size field, the limit is the Gateway's own, and this chart
|
||||
# deliberately renders no implementation-specific policy object to set it
|
||||
# (Gitea #114). NOTES.txt says so in both modes.
|
||||
maxUploadFileSize: "10m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
@@ -116,151 +107,13 @@ cwa:
|
||||
env: []
|
||||
|
||||
ingress:
|
||||
# Set to false to omit the Ingress entirely. Nothing inside the pod reads
|
||||
# ingress.domain - no environment variable, no probe Host header, no URL the
|
||||
# app builds itself - so with the Ingress off, clusterIssuer and domain are
|
||||
# both unnecessary. That is still true, but no longer the whole sentence:
|
||||
# the HTTPRoute below is a SECOND reader of ingress.domain (while
|
||||
# httpRoute.hostnames is empty), so "domain is not needed" holds only while
|
||||
# BOTH routing modes are off.
|
||||
#
|
||||
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
|
||||
# true the render aborts, naming both values.
|
||||
# Set to false to omit the Ingress entirely. Unlike some other charts here,
|
||||
# ingress.domain is read nowhere but in the Ingress template, so switching
|
||||
# the Ingress off makes both domain and clusterIssuer unnecessary.
|
||||
enabled: true
|
||||
clusterIssuer: null
|
||||
# The public hostname of this release. Read by the Ingress and - when
|
||||
# httpRoute.hostnames is empty - by the HTTPRoute, and by nothing else.
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer and proxy-body-size annotations the chart always sets.
|
||||
#
|
||||
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
|
||||
# annotation channel for controller behaviour, so anything configured here is
|
||||
# simply gone when httpRoute.enabled is used instead - it is then the
|
||||
# Gateway's business, or that of an implementation-specific policy object
|
||||
# this chart deliberately does not render. For THIS chart that includes the
|
||||
# upload cap: see cwa.maxUploadFileSize above.
|
||||
annotations: {}
|
||||
|
||||
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
|
||||
# an existing release is unaffected; exactly one of ingress.enabled and
|
||||
# httpRoute.enabled may be true.
|
||||
#
|
||||
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
|
||||
# gatewayClassName, no Certificate and no implementation-specific policy
|
||||
# object. Class and certificate belong to the Gateway and therefore to the
|
||||
# platform, which is what keeps this chart independent of whether the cluster
|
||||
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
|
||||
#
|
||||
# WHAT THIS MODE COSTS HERE: cwa.maxUploadFileSize is not enforced. It exists
|
||||
# only as an ingress-nginx annotation and the Gateway API has no body-size
|
||||
# field, so the limit becomes whatever the Gateway enforces. For an e-book
|
||||
# library that is the one value worth checking before switching - a book above
|
||||
# the Gateway's default is rejected by the proxy, and nothing in CWA's log says
|
||||
# a chart value was ignored. NOTES.txt prints this in Gateway mode.
|
||||
#
|
||||
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
|
||||
# cannot detect them (.Capabilities.APIVersions is unreliable at template
|
||||
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
|
||||
httpRoute:
|
||||
enabled: false
|
||||
# The Gateway(s) this route attaches to - a platform object, usually in
|
||||
# another namespace, which must allow this namespace in its listener's
|
||||
# allowedRoutes. One entry per parent, the fields are the Gateway API's
|
||||
# ParentReference (name, and optionally namespace, sectionName, port, group,
|
||||
# kind), passed through unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
# Empty means [ingress.domain], so the hostname has one source of truth. Set
|
||||
# explicitly only to publish under a different or an additional name - which
|
||||
# is how a migration from the Ingress runs: the HTTPRoute takes a second
|
||||
# hostname while the Ingress keeps ingress.domain.
|
||||
#
|
||||
# Cheap in this chart, because nothing inside the pod follows the hostname:
|
||||
# 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: []
|
||||
@@ -1,69 +0,0 @@
|
||||
# Fixtures for the bulwark install-test: the two Secrets the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItems the chart renders
|
||||
# (release name: ci, stub CRD in ci/common/), plus a name for the JMAP endpoint
|
||||
# to resolve to.
|
||||
#
|
||||
# NO DATABASE, NO CACHE, NO BROKER AND NO MAIL SERVER - and that is a property of
|
||||
# the chart rather than a shortcut taken here. Bulwark is a client: it holds no
|
||||
# mail and contacts the JMAP server per request, not at boot, so it starts and
|
||||
# becomes Ready with nothing behind that URL. The flip side is the limitation the
|
||||
# chart documents at bulwark.readinessProbe, and this file is the clearest
|
||||
# illustration of it: everything needed to make this chart green is in these few
|
||||
# objects, and none of it is a mail server.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-app-secret
|
||||
stringData:
|
||||
# The field NAMES are what matter here, not the values: the operator
|
||||
# materializes one Secret key per item field, and the chart consumes these two
|
||||
# in DIFFERENT ways - session-secret as a projected FILE, admin-password
|
||||
# through a secretKeyRef.
|
||||
#
|
||||
# Only the second of those is actually asserted by the run. The projected
|
||||
# volume is `optional: true` by design (so a first install does not hang
|
||||
# waiting for the 1Password operator), so a misspelled session-secret yields a
|
||||
# missing file and a pod that starts fine. A misspelled admin-password leaves
|
||||
# the container in CreateContainerConfigError and fails the run.
|
||||
#
|
||||
# A real base64 32-byte value rather than a placeholder word: Bulwark derives
|
||||
# encryption keys from it, and a value of the wrong shape would be the kind of
|
||||
# thing that works in CI and not in production.
|
||||
session-secret: Y2ktb25seS1zZXNzaW9uLXNlY3JldC1ub3QtYS1yZWFsLW9uZQ==
|
||||
# Visibly throwaway. It is hashed with scrypt into admin.json on the first
|
||||
# start and the cluster is deleted with the job; the only thing it protects is
|
||||
# a dashboard that exists for a few minutes.
|
||||
admin-password: ci-bulwark-admin-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-bulwark-oauth-secret
|
||||
stringData:
|
||||
# The OIDC client secret. Nothing in CI performs a token exchange - no identity
|
||||
# provider runs here - so this exists to exercise the SECOND source of the
|
||||
# chart's projected volume and the OAUTH_CLIENT_SECRET_FILE path, not the OAuth
|
||||
# flow.
|
||||
oauth-client-secret: ci-bulwark-oauth-client-secret
|
||||
---
|
||||
# The JMAP endpoint's name. A Service with NO SELECTOR and therefore no
|
||||
# endpoints: the in-cluster DNS name resolves, and a connection to it is refused.
|
||||
#
|
||||
# DELIBERATELY NOT A DEPLOYMENT. ci/run-install-test.sh waits for every fixture
|
||||
# Deployment and StatefulSet to roll out BEFORE it installs the chart, so a stub
|
||||
# web server here would be one more workload able to fail a run that is not about
|
||||
# it - and it would buy nothing, because a refused connection and a served 404
|
||||
# are indistinguishable to a chart that never contacts this address at boot.
|
||||
#
|
||||
# It exists at all so the URL in test-values.yaml is a real name rather than an
|
||||
# invented hostname, which keeps the fixture honest about what the chart does
|
||||
# with it.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jmap-stub
|
||||
spec:
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -1,174 +0,0 @@
|
||||
# CI values for the bulwark install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth.
|
||||
# Bulwark needs no database, no cache and no broker, and it does not need a
|
||||
# reachable JMAP server to START - the connection is made per request, not at
|
||||
# boot - so a dummy URL is enough to bring the pod up. That makes this one of
|
||||
# the cheapest install-tests in the repo and the only place where the two
|
||||
# riskiest assumptions of the chart are exercised at all. Neither is observable
|
||||
# in a render.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES:
|
||||
#
|
||||
# 1. readOnlyRootFilesystem: true HOLDS, WITH EXACTLY THE TWO emptyDirs THE
|
||||
# CHART RENDERS - /tmp and /app/.next/cache. This is the measurement point
|
||||
# the plan for Gitea #89 left open, and it is the reason this fixture
|
||||
# exists: the set of writable paths was DERIVED from next.config.ts
|
||||
# (output: "standalone", and images.unoptimized applies only in the isLite
|
||||
# branch, so image optimization is active and writes under
|
||||
# /app/.next/cache) and could not be measured without running the image.
|
||||
# A missing writable path is not subtle - it is an EROFS or EACCES on the
|
||||
# first write - so a pod that reaches Ready and stays Ready is the proof.
|
||||
# A SUPERFLUOUS emptyDir would NOT be caught here; that half stays open and
|
||||
# is named in the chart's wiki page.
|
||||
# 2. THE PROBES CAN REALLY BE SATISFIED. All three are an HTTP GET on
|
||||
# /api/health, which has to exist in the built bundle and answer 200
|
||||
# unauthenticated. If it did not, the startup probe would exhaust its budget
|
||||
# and the rollout would fail - and no render could have told us, because a
|
||||
# path only exists in the built bundle.
|
||||
# 3. The uid/gid 1001 read off the Dockerfile is right, and fsGroup makes a
|
||||
# freshly provisioned volume writable for it: Bulwark creates its four
|
||||
# subdirectories under /app/data itself on start, and the image's own mkdir
|
||||
# ran in a layer this mount covers. Wrong uid, or no fsGroup, and the
|
||||
# instrumentation hook fails to write admin.json.
|
||||
# 4. ADMIN_PASSWORD is really consumed on the first start - the hook hashes it
|
||||
# with scrypt and writes /app/data/admin/admin.json. That write is inside
|
||||
# the startup budget, so a budget that was too short would fail here.
|
||||
# Plus the mechanical half: the projected volume assembles, the Service port
|
||||
# name matches the containerPort, the Ingress is accepted with its extra
|
||||
# annotation alongside the cert-manager one, the resource quantities are valid,
|
||||
# and the schema accepts a realistic non-default values file.
|
||||
#
|
||||
# ONE CLAIM THIS FIXTURE DELIBERATELY DOES NOT MAKE, because it would be
|
||||
# false: it does not prove that the two FILE-form secret keys are spelled
|
||||
# right. The projected volume marks both sources `optional: true` on purpose -
|
||||
# so that a first install does not hang in ContainerCreating while the
|
||||
# 1Password operator catches up - and the consequence is that a misspelled
|
||||
# session-secret or oauth-client-secret key yields a MISSING FILE and a pod
|
||||
# that starts perfectly. Only `admin-password` is proven, because it arrives
|
||||
# through a non-optional secretKeyRef and a wrong name there leaves the
|
||||
# container in CreateContainerConfigError.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and the first two matter most:
|
||||
# - ANYTHING ABOUT JMAP. The URL below points at a Service that answers
|
||||
# nothing useful. Bulwark comes up regardless, which is precisely the
|
||||
# property the readiness probe cannot see (chart values.yaml,
|
||||
# bulwark.readinessProbe) - so this test demonstrates the limitation rather
|
||||
# than closing it. A green run here says nothing about mail working.
|
||||
# - ANYTHING ABOUT OIDC. No identity provider runs in CI, so the sign-in path
|
||||
# is never walked. What IS checked is that the chart's render guards fire on
|
||||
# a missing client id or issuer (unit-testable at render time) and that the
|
||||
# secret file is mounted where OAUTH_CLIENT_SECRET_FILE points.
|
||||
# - THE PRECEDENCE TRAP. config.json never exists in this run, so the
|
||||
# "volume beats the chart" behaviour - the chart's single most important
|
||||
# operating caveat - is not exercised. It cannot be: it needs an admin
|
||||
# writing an override, which needs a browser.
|
||||
# - anything about an upgrade, or about a volume that already carries data.
|
||||
# This installs once, from nothing.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/bulwark/fixtures.yaml as ci-bulwark-app-secret and
|
||||
# ci-bulwark-oauth-secret (the OnePasswordItem stub CRD comes from ci/common/).
|
||||
appSecretRef: op://ci/bulwark-app
|
||||
oauthSecretRef: op://ci/bulwark-oauth
|
||||
|
||||
bulwark:
|
||||
# A dummy JMAP endpoint. It does NOT have to answer - Bulwark connects per
|
||||
# request - but it does have to be SET, because an empty value puts the app
|
||||
# into bootstrap mode and serves the setup wizard instead. So this value also
|
||||
# exercises the path that keeps the chart declarative.
|
||||
#
|
||||
# It points at the selector-less Service in fixtures.yaml: a name that RESOLVES
|
||||
# and then refuses the connection. That is deliberately not a Deployment -
|
||||
# fixtures with a workload have to roll out before the chart is installed, so a
|
||||
# stub serving nothing would be one more thing able to fail a run that is not
|
||||
# about it. A refused connection and a served 404 are indistinguishable to this
|
||||
# chart, because nothing here contacts the JMAP server at boot.
|
||||
jmapServerUrl: http://jmap-stub.default.svc.cluster.local:8080
|
||||
|
||||
appName: Bulwark CI
|
||||
|
||||
# OIDC LEFT ON, i.e. the chart's default, although no provider answers in CI.
|
||||
# That is deliberate: it is what makes the run exercise the OAuth
|
||||
# OnePasswordItem, the second source of the projected volume and the
|
||||
# OAUTH_CLIENT_SECRET_FILE path. Bulwark does not contact the issuer at boot,
|
||||
# so an unreachable one costs nothing here - it would only fail a sign-in, and
|
||||
# nothing signs in.
|
||||
oauth:
|
||||
enabled: true
|
||||
only: true
|
||||
clientId: bulwark-ci
|
||||
issuerUrl: https://keycloak.ci.example.com/realms/ci
|
||||
|
||||
# ON, i.e. the chart's default, and it is what makes /app/data/settings get
|
||||
# created and the session secret actually read: with the sync off, a missing or
|
||||
# misnamed session-secret field would pass unnoticed.
|
||||
settingsSync: true
|
||||
|
||||
# OFF - the chart's default, spelled out because it is load-bearing for what
|
||||
# this test means. With it ON, a fresh install cannot write admin.json and the
|
||||
# admin dashboard never comes up; the pod would still be Ready, so the run
|
||||
# would be green while the chart's documented rescue door was missing. Leaving
|
||||
# it off is what makes step 4 of "what a green run proves" an assertion.
|
||||
adminConfigReadOnly: false
|
||||
|
||||
# Both off, as in the chart - and here with a second reason: a CI runner should
|
||||
# not send a telemetry heartbeat or an update check to a vendor endpoint on
|
||||
# every pull request.
|
||||
telemetry: false
|
||||
updateCheck: false
|
||||
|
||||
# debug, so that a failed run has something to read. The install-test dumps
|
||||
# container logs on failure, and Bulwark's info level says very little about
|
||||
# why a start went wrong.
|
||||
logLevel: debug
|
||||
# text rather than the chart's json: a human reads these logs out of a failed
|
||||
# job, not a collector.
|
||||
logFormat: text
|
||||
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the limit
|
||||
# derivation: 192Mi x limitFactor 3 = 576Mi. Not smaller than this on
|
||||
# purpose - the memory LIMIT is what V8's heap limit is derived from, and a
|
||||
# Next.js server with too little heap fails in a way that looks like an
|
||||
# application fault.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 192Mi
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
storage:
|
||||
amount: 256Mi
|
||||
# k3d's dynamic provisioner. The intended production value does not exist on
|
||||
# the runner - and a pre-bound hostPath PV would be worse than this one,
|
||||
# because the kubelet does not apply fsGroup to a hostPath volume at all, so
|
||||
# the test would silently stop exercising the ownership path that matters most
|
||||
# here: Bulwark has to CREATE four subdirectories in a volume it did not make.
|
||||
storageClass: local-path
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the Service port it names BY NAME ("http")
|
||||
# actually exists - a typo there is otherwise invisible until someone opens the
|
||||
# URL.
|
||||
enabled: true
|
||||
domain: bulwark.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
annotations:
|
||||
# Exercises the annotation merge: this has to arrive ALONGSIDE the
|
||||
# cert-manager annotation the chart always sets, not instead of it. It is
|
||||
# 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
|
||||
@@ -1,40 +1,6 @@
|
||||
# cyberchef: stateless, no secrets.
|
||||
#
|
||||
# THIS CHART IS INSTALLED IN GATEWAY MODE, NOT IN INGRESS MODE (Gitea #114),
|
||||
# and that is a deliberate trade, because the install-test runs exactly ONE
|
||||
# configuration per chart and the two modes are mutually exclusive.
|
||||
#
|
||||
# What is given up: the Ingress of this one chart is no longer installed
|
||||
# anywhere. That costs little. An Ingress is a built-in, GA type whose schema
|
||||
# the API server has validated for a decade, k3d's own ingress controller is
|
||||
# traefik while this chart asks for the class "nginx", so nothing ever routed
|
||||
# through it here either - the install only ever proved "the API server
|
||||
# accepted an Ingress". And it keeps being proved, twenty times over, by every
|
||||
# other chart in the repo plus `helm lint --strict`, which renders this chart's
|
||||
# DEFAULTS, i.e. Ingress mode, on every pull request.
|
||||
#
|
||||
# What is gained: a real HTTPRoute, applied against the real Gateway-API CRDs
|
||||
# (see ci/gateway-api-crds.sh), which is a CRD whose schema nothing else in
|
||||
# this repository has ever exercised. That is where a malformed parentRefs or
|
||||
# backendRefs is actually caught.
|
||||
#
|
||||
# Still NOT proved by this, and it cannot be: that traffic flows. No Gateway
|
||||
# controller runs in k3d, so nothing routes. The install-test proves the object
|
||||
# is schema-valid and the pod becomes Ready; ci/run-httproute-match-test.sh
|
||||
# proves the route points at a Service the same render creates.
|
||||
# cyberchef: stateless, no secrets. Only the required ingress values are set.
|
||||
# The Ingress just has to apply - there is no ingress-nginx/cert-manager in
|
||||
# the CI cluster.
|
||||
ingress:
|
||||
# The hostname is read by the HTTPRoute too (httpRoute.hostnames is empty, so
|
||||
# it falls back to ingress.domain) - it is the release's public name, not an
|
||||
# Ingress switch.
|
||||
domain: cyberchef.ci.local
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
# The Gateway does not exist in the CI cluster, and it does not need to: a
|
||||
# parentRef is resolved by the Gateway controller, of which there is none
|
||||
# here, so the reference is stored as written. What the real CRDs DO validate
|
||||
# is its shape.
|
||||
parentRefs:
|
||||
- name: ci-gateway
|
||||
namespace: default
|
||||
sectionName: https
|
||||
clusterIssuer: ci
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the REAL Gateway-API CRDs (standard channel) into the cluster of the
|
||||
# current kubectl context, for the install-test of a chart that renders an
|
||||
# HTTPRoute (Gitea #114).
|
||||
#
|
||||
# Usage: ci/gateway-api-crds.sh (called by ci/run-install-test.sh)
|
||||
#
|
||||
# WHY NOT A STUB CRD, WHICH IS WHAT ci/common/ DOES FOR OnePasswordItem
|
||||
#
|
||||
# Because a stub would make the install-test prove nothing. ci/common's
|
||||
# OnePasswordItem CRD is schema-less on purpose (x-kubernetes-preserve-unknown-
|
||||
# fields): nobody is testing the 1Password operator's API, only that the chart's
|
||||
# CR applies. For an HTTPRoute the situation is the opposite - the SCHEMA is the
|
||||
# entire value of the exercise. With a schema-less stub the API server accepts a
|
||||
# malformed parentRefs, a backendRef with a port written as a string, a
|
||||
# path type that does not exist; the install goes green and the only thing
|
||||
# demonstrated is "something was created". With the real CRDs the same apply is
|
||||
# validated field by field, and a malformed route fails the install-test loudly.
|
||||
#
|
||||
# This is the half that CANNOT be moved into a render test: a render cannot know
|
||||
# the Gateway API's schema. And the half the CRDs cannot cover is held by
|
||||
# ci/run-httproute-match-test.sh - schema validation accepts an empty
|
||||
# parentRefs list and a backendRef naming a Service that does not exist.
|
||||
#
|
||||
# What neither proves, and nothing here can: that traffic flows. No Gateway
|
||||
# controller runs in k3d, so no route is ever reconciled and no status is ever
|
||||
# written. Provable: the object is schema-valid and the pod becomes Ready.
|
||||
#
|
||||
# WHY --server-side, AND THE MEASUREMENT BEHIND IT
|
||||
#
|
||||
# Client-side `kubectl apply` stores the whole applied object in the
|
||||
# kubectl.kubernetes.io/last-applied-configuration annotation, and an object's
|
||||
# annotations may total at most 262144 bytes. The HTTPRoute CRD of the standard
|
||||
# channel is 429367 bytes of YAML, which comes to 244792 bytes as JSON
|
||||
# (measured on v1.6.3, re-read through ci/yaml2json) - i.e. 93% of the limit,
|
||||
# with every release adding fields. So client-side apply happens to fit today
|
||||
# and is one upstream release away from failing with "metadata.annotations: Too
|
||||
# long", on a Renovate PR that has nothing to do with it. Server-side apply
|
||||
# writes no such annotation and has no such limit.
|
||||
#
|
||||
# This is also why the CRDs are NOT in ci/common/, which ci/run-install-test.sh
|
||||
# applies client-side for EVERY chart: putting them there would hand that
|
||||
# landmine to all 20-odd charts and spend the download on charts that render no
|
||||
# route at all.
|
||||
#
|
||||
# WHY DOWNLOADED AND NOT VENDORED
|
||||
#
|
||||
# 1.1 MB of generated YAML in the repository would go stale invisibly and would
|
||||
# bury every diff that touches ci/. Downloading a PINNED artifact is what this
|
||||
# repo already does for every other piece of CI tooling - Trivy's tarball, k3d's
|
||||
# install script, kubectl - each with a `# renovate:` comment keeping the pin
|
||||
# current. The cost is named rather than hidden: one more network fetch, and if
|
||||
# the release asset ever disappears this goes red on a pull request that changed
|
||||
# nothing related. It fails loudly, which is the acceptable half.
|
||||
set -euo pipefail
|
||||
|
||||
# The pinned standard channel. Renovate keeps this current through the
|
||||
# customManager for ci/*.sh in renovate.json5; prereleases are excluded there,
|
||||
# so the -rc.N tags upstream publishes are never offered.
|
||||
# renovate: datasource=github-releases depName=kubernetes-sigs/gateway-api
|
||||
GATEWAY_API_VERSION=v1.6.3
|
||||
|
||||
# The standard channel, as published - the whole set and not a hand-picked
|
||||
# subset. The charts render only HTTPRoute today, so HTTPRoute's CRD is the only
|
||||
# one strictly needed; taking the published set instead means the sweep of #114
|
||||
# needs no change here, and a fixture that wants a real Gateway object later
|
||||
# finds its CRD already installed.
|
||||
URL="https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/standard-install.yaml"
|
||||
|
||||
MANIFEST="$(mktemp)"
|
||||
trap 'rm -f "$MANIFEST"' EXIT
|
||||
|
||||
echo "==> Gateway API ${GATEWAY_API_VERSION} (standard channel): $URL"
|
||||
curl -sSfL -o "$MANIFEST" "$URL"
|
||||
|
||||
# A truncated or redirected download would otherwise be applied as whatever it
|
||||
# happens to contain. Asserting the one object the charts actually need is
|
||||
# cheaper than a checksum nobody can regenerate, and it names what is wrong.
|
||||
if ! grep -q '^ name: httproutes.gateway.networking.k8s.io$' "$MANIFEST"; then
|
||||
echo "ERROR: the downloaded manifest does not contain the httproutes CRD." >&2
|
||||
echo "That is not a chart failure: either the release asset changed shape or the" >&2
|
||||
echo "download was truncated. Got $(wc -c <"$MANIFEST") bytes from $URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kubectl apply --server-side -f "$MANIFEST"
|
||||
|
||||
# Established, not just created. `kubectl apply` returns as soon as the CRD
|
||||
# object exists, while the API server needs a moment to serve the new endpoint -
|
||||
# and a `helm install` in that window fails with "no matches for kind
|
||||
# HTTPRoute", which reads exactly like a missing CRD rather than like a race.
|
||||
# Same lesson as the Ready-node loop in install-test.yml: wait for the
|
||||
# condition that means usable, not for the one that means present.
|
||||
for CRD in $(kubectl get crd -o name | grep '\.gateway\.networking\.k8s\.io$'); do
|
||||
kubectl wait --for=condition=Established "$CRD" --timeout=60s
|
||||
done
|
||||
|
||||
kubectl get crd -o name | grep '\.gateway\.networking\.k8s\.io$' | sed 's/^/ /'
|
||||
echo "==> Gateway API CRDs established."
|
||||
@@ -1,31 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# WHY AN OVERLAY AND NOT A CHANGE TO ci/bentopdf/test-values.yaml, which is
|
||||
# what the pilot did for cyberchef: the install-test runs exactly ONE
|
||||
# configuration per chart, and the two modes are mutually exclusive. cyberchef
|
||||
# already pays that price and gives the install-test its HTTPRoute against the
|
||||
# real Gateway-API CRDs. Spending it a second time here would drop this chart's
|
||||
# Ingress from the install-test for a schema this repository now exercises
|
||||
# anyway. So the install keeps testing the Ingress; the route is held at render
|
||||
# level, which is where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here. The two modes are mutually
|
||||
# exclusive and the chart aborts its own render when both are true, so an
|
||||
# overlay that only switched httpRoute on would turn this test red.
|
||||
#
|
||||
# ingress.domain comes from ci/bentopdf/test-values.yaml, which this overlay
|
||||
# sits on top of - httpRoute.hostnames is left empty on purpose, so the
|
||||
# fallback to ingress.domain is what the test exercises.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,54 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/bulwark/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart, and this chart's fixture installs
|
||||
# the StatefulSet with its PVC and both 1Password items. Trading its Ingress
|
||||
# for a CRD schema check that cyberchef's fixture already performs against the
|
||||
# real Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
|
||||
# route is held here, at render level, which is where its silent failure modes
|
||||
# live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
|
||||
# routing switch and defaults to TRUE, so without this line the chart aborts
|
||||
# its own render with both modes on.
|
||||
#
|
||||
# ONE ROUTING OBJECT, WHICH IS ALL THIS CHART HAS. #114's measured table and
|
||||
# the header of ci/run-httproute-match-test.sh both expected two Ingresses and
|
||||
# a rewrite-target here; neither is the case, and the reason is in
|
||||
# bulwark.httproute.yaml. In short: Bulwark cannot be served under a sub-path
|
||||
# (NEXT_PUBLIC_BASE_PATH is compiled into the bundle), so there is one object,
|
||||
# one rule, at "/", and no URLRewrite filter for the #118 prefix/redirect
|
||||
# assurance to pair up with. Every "rewrite-target" string in this chart is an
|
||||
# explanation of why it does not work, and a grep counted those as
|
||||
# occurrences.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS:
|
||||
#
|
||||
# - ingress.domain comes from the fixture, so the route renders with its
|
||||
# hostname from that single source of truth - httpRoute.hostnames stays
|
||||
# empty on purpose, because the fallback is the path every installation
|
||||
# will actually use.
|
||||
# - the route's backendRef is checked against the serving Service of the same
|
||||
# render, by PORT NUMBER (bulwark.port) rather than by the name "http" the
|
||||
# Ingress uses, because backendRefs takes a PortNumber. This overlay is
|
||||
# what holds that number against the Service's.
|
||||
#
|
||||
# NOT covered here, and it cannot be: that the hostname answers. No Gateway
|
||||
# controller runs in CI and no route reaches "Accepted: True". Nor the thing
|
||||
# this chart actually loses in this mode - the operator's
|
||||
# proxy-body-size: 25m, which has no counterpart in the specification. That
|
||||
# loss is documented in values.yaml and printed by NOTES.txt, and documentation
|
||||
# is not something a test can assert.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,28 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/calibre-web-automated/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart and the two modes are
|
||||
# mutually exclusive, so switching the fixture would drop this chart's Ingress
|
||||
# from the install for a CRD schema cyberchef's fixture already exercises
|
||||
# against the real Gateway-API CRDs. The install keeps the Ingress; the route is
|
||||
# held here, at render level, which is where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/calibre-web-automated/test-values.yaml;
|
||||
# httpRoute.hostnames is left empty on purpose, so the fallback to
|
||||
# ingress.domain is what the test exercises and the two cannot disagree here.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,55 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/freesailarr/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart, and this chart's
|
||||
# fixture is the expensive one - it brings up a real Wireguard tunnel so that
|
||||
# ten containers can become Ready. Switching it into Gateway mode would trade
|
||||
# its Ingress objects for a CRD schema cyberchef's fixture already exercises
|
||||
# against the real Gateway-API CRDs. The install keeps the Ingresses; the
|
||||
# routes are held here, at render level, which is where their two silent
|
||||
# failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
|
||||
# MASTER switch and defaults to true, so without this line the chart aborts its
|
||||
# own render with both modes on.
|
||||
#
|
||||
# WHAT THIS OVERLAY ACTUALLY COVERS, which is more than one route:
|
||||
#
|
||||
# - ingress.host comes from the fixture (freesailarr.example.com), so the
|
||||
# SHARED-domain shape is what renders: one HTTPRoute with one rule per app.
|
||||
# - qbittorrent is enabled in the fixture, so its rule is in that route WITH
|
||||
# the URLRewrite filter - the per-rule equivalent of the rewrite-target
|
||||
# annotation, and the reason this chart needs no second routing object in
|
||||
# this mode.
|
||||
# - mediathekarr is enabled in the fixture with its ingress off, so the
|
||||
# per-app selector is exercised in the negative direction too.
|
||||
# - every rule's backendRef is checked against the per-app Services of the
|
||||
# same render, which is where this chart differs from the rest of the
|
||||
# sweep: it has one Service per app and the route carries the port NUMBER,
|
||||
# not the name the Ingress uses.
|
||||
#
|
||||
# There is no httpRoute.hostnames key in this chart (see values.yaml): the
|
||||
# hostnames come from ingress.host and <app>.ingress.host, which the fixture
|
||||
# already sets.
|
||||
#
|
||||
# A SECOND APP IS GIVEN A HOST OF ITS OWN so the MIXED shape is covered as
|
||||
# well: bazarr then renders its own route (ci-freesailarr-bazarr-httproute)
|
||||
# while the rest stay on the shared host. Without it the per-app branch of the
|
||||
# template would never be entered by any test. Bazarr is the right one to move
|
||||
# because its sub-path mode needs a manual step inside the application anyway.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
bazarr:
|
||||
ingress:
|
||||
host: bazarr.ci.local
|
||||
@@ -1,35 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# THIS CHART IS THE ONE WHERE THE OVERLAY EARNS ITS KEEP TWICE OVER, and the
|
||||
# reason is in ci/garage/test-values.yaml: that fixture keeps the Ingress ON
|
||||
# deliberately, so the install-test proves the Service port it names BY NAME
|
||||
# ("s3") exists. An HTTPRoute cannot make that reference - backendRefs takes a
|
||||
# PortNumber - so garage is the one chart in the sweep whose route renders a
|
||||
# port NUMBER out of a VALUE (garage.ports.s3), not a literal. Switching the
|
||||
# install fixture into Gateway mode would have traded a check that catches a
|
||||
# renamed port for one that does not, and would have given up garage's only
|
||||
# Ingress coverage at the same time. Here the number is held instead by this
|
||||
# test, which fails when a backendRef names a port the same render's Service
|
||||
# does not expose - exactly the drift the value introduces.
|
||||
#
|
||||
# ingress.enabled: false is not optional here. The two modes are mutually
|
||||
# exclusive and the chart aborts its own render when both are true, and this
|
||||
# chart's fixture has the Ingress on, so the overlay must switch it off.
|
||||
#
|
||||
# ingress.domain comes from ci/garage/test-values.yaml. Note that garage ships
|
||||
# ingress.enabled: false AND httpRoute.enabled: false, so without this overlay
|
||||
# the chart renders no route at all - which is correct and is why the overlay
|
||||
# exists rather than a changed default.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,79 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/homeassistant/test-values.yaml, and here
|
||||
# that is more than the usual reason. This chart's ingress.enabled DEFAULT IS
|
||||
# false and the install-test fixture does not switch it on, so the install
|
||||
# exercises no routing object at all - as with garage. There is nothing to
|
||||
# trade away: this overlay ADDS coverage that the install-test never had,
|
||||
# rather than swapping one object for another.
|
||||
#
|
||||
# ingress.enabled: false is restated anyway. It is already the default, but the
|
||||
# two modes are mutually exclusive and the chart aborts its own render when
|
||||
# both are true, so writing it down here keeps this overlay correct if that
|
||||
# default ever changes.
|
||||
#
|
||||
# AND THIS OVERLAY HAS TO SUPPLY HOSTS, which no other overlay in the sweep
|
||||
# does. This chart is the DOCUMENTED INGRESS EXCEPTION: it has no
|
||||
# ingress.domain, so there is no httpRoute.hostnames either - the hostnames and
|
||||
# the paths come from ingress.hosts, and the chart renders ONE HTTPRoute PER
|
||||
# ENTRY there. The chart's default entry is the placeholder
|
||||
# "chart-example.local" with pathType ImplementationSpecific, which WOULD
|
||||
# render (the chart maps ImplementationSpecific to PathPrefix on "/"), but it
|
||||
# would exercise exactly one route with one rule and would leave the
|
||||
# interesting half of this chart's flexible style untested.
|
||||
#
|
||||
# So the list below is deliberate, and each entry earns its place:
|
||||
#
|
||||
# 1. ha.ci.local with "/" and pathType Prefix - the ordinary case, and the
|
||||
# clean Prefix -> PathPrefix mapping.
|
||||
# 2. ha-alt.ci.local with TWO paths, one Prefix and one Exact - this is the
|
||||
# part that cannot be expressed in a single HTTPRoute together with entry
|
||||
# 1, because an HTTPRoute's rules apply to EVERY hostname of the object.
|
||||
# It is therefore the test that the chart really renders one object per
|
||||
# host entry rather than one object with several hostnames, and it
|
||||
# exercises the Exact mapping and the multi-rule path at the same time.
|
||||
#
|
||||
# Both entries' rules are checked against the Service of the same render. The
|
||||
# reference is by PORT NUMBER (service.port) rather than by the name "http" the
|
||||
# Ingress uses, because backendRefs takes a PortNumber - so this overlay is
|
||||
# what holds that number against the Service's.
|
||||
#
|
||||
# NOT covered here, and it cannot be:
|
||||
#
|
||||
# - that either hostname answers. No Gateway controller runs in CI and no
|
||||
# route reaches "Accepted: True".
|
||||
# - the pathType ImplementationSpecific ABORT on a non-root path. That is a
|
||||
# render-failure path, and this test only runs renders it expects to
|
||||
# succeed; it was exercised by hand instead (see the PR).
|
||||
# - that Home Assistant accepts the Host header or reads X-Forwarded-For.
|
||||
# Both live in the application's own configuration, which arrives from
|
||||
# 1Password, and changing the data path changes the proxy address the
|
||||
# trusted_proxies list has to name. NOTES.txt says so; nothing can assert
|
||||
# it here.
|
||||
#
|
||||
# No URLRewrite filter is rendered by this chart in either mode, so the #118
|
||||
# prefix/redirect assurance has no precondition to meet here.
|
||||
ingress:
|
||||
enabled: false
|
||||
hosts:
|
||||
- host: ha.ci.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
- host: ha-alt.ci.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
- path: /api/websocket
|
||||
pathType: Exact
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,70 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/keycloak/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart, and this chart's fixture is the
|
||||
# expensive and informative one - two replicas against a throwaway PostgreSQL,
|
||||
# two init containers, `kc.sh build` under readOnlyRootFilesystem, and the
|
||||
# Infinispan cluster forming over jdbc-ping (see that file for the four things
|
||||
# only a real install can prove). Trading all of it for a CRD schema check that
|
||||
# cyberchef's fixture already performs against the real Gateway-API CRDs would
|
||||
# be a bad swap. The install keeps the two Ingresses; the two routes are held
|
||||
# here, at render level, which is where their silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here. The two modes are mutually
|
||||
# exclusive and the chart aborts its own render when both are true, so an
|
||||
# overlay that only switched httpRoute on would turn this test red.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS:
|
||||
#
|
||||
# - ingress.domain comes from the fixture (keycloak.ci.example.com), so the
|
||||
# PUBLIC route renders with its hostname from that single source of truth
|
||||
# (httpRoute.hostnames stays empty on purpose - the fallback is the path
|
||||
# that every installation will actually use).
|
||||
# - ingress.admin.enabled is true in the fixture with
|
||||
# ingress.admin.domain set, so the ADMIN route renders too, and its
|
||||
# parentRefs fall back to httpRoute.parentRefs below - the empty-admin
|
||||
# branch, which is the one an installation hits unless it wants an
|
||||
# internal Gateway.
|
||||
# - every route's backendRef is checked against the serving Service of the
|
||||
# same render. They all point at the SAME Service, by PORT NUMBER
|
||||
# (keycloak.ports.http) rather than by the name "http" the Ingress uses,
|
||||
# because backendRefs takes a PortNumber - so this overlay is what holds
|
||||
# that number against the Service's.
|
||||
# - THE TWO REALM ROUTES (Gitea #122), which come from keycloak.realmHosts in
|
||||
# the fixture rather than from this file. Nothing is repeated here on
|
||||
# purpose: the fixture installs them as Ingress objects and this overlay
|
||||
# turns the same two entries into HTTPRoutes, so one definition covers both
|
||||
# modes and the two halves cannot drift into describing different hostnames.
|
||||
# What the routes add to the two above is a SECOND RULE SHAPE - an Exact
|
||||
# match on / carrying a RequestRedirect filter and no backendRefs at all.
|
||||
# That rule is reported as "not checked" by this test, correctly and by
|
||||
# design: a filter-only rule has no backend to resolve. The assertion it
|
||||
# does earn is on its sibling rule, the PathPrefix one that points at the
|
||||
# Service. The prefix/redirect pairing check of #118 does NOT fire for these
|
||||
# routes and must not: it is scoped to rules that STRIP a prefix with a
|
||||
# ReplacePrefixMatch rewrite, and these strip nothing - they redirect the
|
||||
# bare root of a hostname that serves one realm.
|
||||
# The entries also cover the two Ingress-mode forms (one with a
|
||||
# clusterIssuer, one without) and what this test holds about them is their
|
||||
# INERTNESS: neither tlsSecretName nor clusterIssuer nor the per-entry
|
||||
# annotation may appear anywhere in a rendered route.
|
||||
#
|
||||
# NOT covered here, and it cannot be: that either hostname answers. No Gateway
|
||||
# controller runs in CI, no route reaches "Accepted: True", and the
|
||||
# proxy-buffer-size/proxy-buffers-number annotations this chart's live
|
||||
# installation relies on have no counterpart in this mode at all - that loss is
|
||||
# documented in values.yaml and printed by NOTES.txt, and documentation is not
|
||||
# something a test can assert.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,59 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/matrix-synapse/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart, and this chart's
|
||||
# fixture installs the StatefulSet with its PVC and the delegation Deployment.
|
||||
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
|
||||
# performs against the real Gateway-API CRDs would be a bad swap. The install
|
||||
# keeps the Ingress; the routes are held here, at render level, which is where
|
||||
# their silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
|
||||
# routing switch and defaults to TRUE, so without this line the chart aborts
|
||||
# its own render with both modes on.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS - and this chart is
|
||||
# the one in group 3 where "both" means two different hostnames, two different
|
||||
# paths and two different backends:
|
||||
#
|
||||
# - ingress.domain comes from the fixture (matrix.ci.local), so the
|
||||
# HOMESERVER route renders with its hostname from that single source of
|
||||
# truth. httpRoute.hostnames stays empty on purpose: the fallback is the
|
||||
# path every installation will actually use.
|
||||
# - matrix.delegation.enabled is TRUE in the fixture with
|
||||
# matrix.delegation.serverUrl: ci.local, so the DELEGATION route renders
|
||||
# too - on its own hostname, matching PathPrefix /.well-known/matrix, and
|
||||
# pointing at the delegation Service rather than at Synapse. Its
|
||||
# parentRefs fall back to httpRoute.parentRefs below, which is the branch
|
||||
# an installation hits unless the server name sits behind a different
|
||||
# Gateway.
|
||||
# - both routes' backendRefs are checked against the Services of the same
|
||||
# render, and they are DIFFERENT Services. Both references are by PORT
|
||||
# NUMBER rather than by the name "http" the Ingress uses, because
|
||||
# backendRefs takes a PortNumber - so this overlay is what holds those
|
||||
# numbers against the Services'.
|
||||
#
|
||||
# No URLRewrite filter is rendered by this chart in either mode: the delegation
|
||||
# nginx answers on the full /.well-known/matrix/* path, so nothing is stripped
|
||||
# and the #118 prefix/redirect assurance has no precondition to meet here.
|
||||
#
|
||||
# NOT covered here, and it cannot be: that either hostname answers, which for
|
||||
# Matrix is also whether FEDERATION works. No Gateway controller runs in CI, no
|
||||
# route reaches "Accepted: True", and Matrix federation is HTTPS-only while
|
||||
# nothing in this mode renders a certificate. Nor is the body-size limit
|
||||
# covered - proxy-body-size: 50m is on the live Ingress and has no counterpart
|
||||
# in the specification; that loss is documented in values.yaml and printed by
|
||||
# NOTES.txt, and documentation is not something a test can assert.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,30 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/outline/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
|
||||
# at render level, which is where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/outline/test-values.yaml and keeps feeding
|
||||
# Outline's URL variable in this mode too; httpRoute.hostnames is left empty on
|
||||
# purpose, so the fallback to ingress.domain is what the test exercises - and
|
||||
# the two therefore cannot disagree here, which is the state this chart needs
|
||||
# for its OIDC redirect URI to match.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,32 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/paperless-ngx/test-values.yaml: that
|
||||
# fixture is the ONLY install of the valkey-subchart path anywhere in this
|
||||
# repository (see its header), and the install-test runs exactly one
|
||||
# configuration per chart. Switching it into Gateway mode would trade that
|
||||
# coverage, plus this chart's Ingress, for a CRD schema cyberchef's fixture
|
||||
# already exercises against the real Gateway-API CRDs. The install keeps the
|
||||
# Ingress and the subchart; the route is held here, at render level, which is
|
||||
# where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here, and in THIS chart it is also
|
||||
# new: the switch was added with the Gateway mode (#114) and defaults to true,
|
||||
# so without this line the chart aborts its own render with both modes on.
|
||||
#
|
||||
# ingress.domain comes from ci/paperless-ngx/test-values.yaml and keeps feeding
|
||||
# PAPERLESS_URL in this mode too; httpRoute.hostnames is left empty on purpose,
|
||||
# so the fallback to ingress.domain is what the test exercises - and the two
|
||||
# therefore cannot disagree here, which is the state Django's CSRF check needs.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,29 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/patchmon/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
|
||||
# at render level, which is where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/patchmon/test-values.yaml and keeps feeding
|
||||
# CORS_ORIGIN and the probes' Host header in this mode too; httpRoute.hostnames
|
||||
# is left empty on purpose, so the fallback to ingress.domain is what the test
|
||||
# exercises - and the two therefore cannot disagree here.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,27 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/rallly/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/rallly/test-values.yaml and keeps feeding
|
||||
# NEXT_PUBLIC_BASE_URL in this mode too; httpRoute.hostnames is left empty on
|
||||
# purpose, so the fallback to ingress.domain is what the test exercises.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,75 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/rss-bridge/test-values.yaml, and for this
|
||||
# chart the reason is sharper than for its siblings. The install-test runs
|
||||
# exactly ONE configuration per chart and the two modes are mutually exclusive,
|
||||
# so switching the fixture would trade this chart's Ingress away - and that
|
||||
# fixture's Ingress is the only thing that exercises rss-bridge.tlsGroups, the
|
||||
# secret-grouping helper that keeps the three grown certificates of the real
|
||||
# installation on their existing names. That helper is inert in Gateway mode
|
||||
# (nothing to group once TLS sits on the listener), so a fixture switched to
|
||||
# this mode would stop covering it entirely. The CRD schema check the pilot
|
||||
# paid for is already performed by cyberchef's fixture against the real
|
||||
# Gateway-API CRDs; what is missing without this file is the render-level
|
||||
# assurance, and that is what this overlay buys.
|
||||
#
|
||||
# A note on this chart's install-test, because it has a history. It had failed
|
||||
# on the Gitea runner since the move - php-fpm unable to read /app/index.php,
|
||||
# the startup probe 403ing through its whole budget, the pod never Ready (Gitea
|
||||
# #51), reproduced on two images while the same chart and image ran healthy in
|
||||
# the cluster. The chart was deliberately kept OUT of ci/excluded-charts.txt so
|
||||
# that a pass would be noticed rather than silenced, and in the PR that added
|
||||
# this overlay it PASSED: pod 1/1 Ready in 22s, 0 restarts, no permission error
|
||||
# in the job. Since that PR's rendered defaults are byte-identical to
|
||||
# origin/main, the chart is ruled out as the cause and the runner is the
|
||||
# implicated layer - which is where #51 had already placed it. It reproduced
|
||||
# on every run of that branch since, so it is not a fluke, and #51 was closed
|
||||
# on that round. What was NEVER identified is the cause: nobody established
|
||||
# what changed on the runner, and the diagnostic programme in that issue
|
||||
# (docker info for userns and the dind configuration, the same image in a
|
||||
# local k3d without remapping) was not executed. So if this chart's
|
||||
# install-test goes red again, that is a fresh finding and not a known quirk -
|
||||
# and this comment is the context it should be read against.
|
||||
#
|
||||
# Either way this overlay stays load-bearing: the install-test installs the
|
||||
# INGRESS mode (that is what the fixture sets), so the Gateway mode of this
|
||||
# chart is covered here at render level and nowhere else - the same situation
|
||||
# as every other chart in the sweep.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart ships it as TRUE and
|
||||
# aborts its own render when both modes are on.
|
||||
#
|
||||
# httpRoute.hostnames MUST be supplied, and this overlay is the second in the
|
||||
# repository that has to (homeassistant's was the first). The other charts let
|
||||
# an empty hostnames list fall back to ingress.domain; this chart has no
|
||||
# ingress.domain, and it deliberately does not reuse ingress.hosts from the
|
||||
# fixture, because those entries carry tlsSecretName values that address
|
||||
# nothing once the certificate sits on the Gateway's listener. The render
|
||||
# aborts on an empty list rather than publishing a route that matches every
|
||||
# request reaching its listener.
|
||||
#
|
||||
# THREE hostnames on ONE object, which is the shape this chart's production
|
||||
# installation has (three hosts, all serving the same Service at "/"). It is
|
||||
# also the shape the test should see: one HTTPRoute, one rule, one backendRef,
|
||||
# several hostnames - as opposed to homeassistant's one-object-per-host or
|
||||
# matrix-synapse's two objects with different paths and backends. The three
|
||||
# names are kept recognisably parallel to ci/rss-bridge/test-values.yaml so the
|
||||
# two modes can be read side by side, but they are NOT the same values: these
|
||||
# are the route's hostnames, and the fixture's are the Ingress's hosts.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
hostnames:
|
||||
- rss-bridge.ci.local
|
||||
- rssbridge.ci.local
|
||||
- rss-bridge.ci.example
|
||||
@@ -1,27 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/searxng/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/searxng/test-values.yaml and keeps feeding
|
||||
# SEARXNG_BASE_URL in this mode too; httpRoute.hostnames is left empty on
|
||||
# purpose, so the fallback to ingress.domain is what the test exercises.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,97 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/stalwart/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart, and this chart's fixture brings up
|
||||
# the node groups against a throwaway Postgres. Trading its Ingress for a CRD
|
||||
# schema check that cyberchef's fixture already performs against the real
|
||||
# Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
|
||||
# route is held here, at render level, which is where its silent failure modes
|
||||
# live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
|
||||
# HTTP routing switch and defaults to TRUE, so without this line the chart
|
||||
# aborts its own render with both modes on.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS:
|
||||
#
|
||||
# - ingress.domain comes from the fixture, so the route renders with its
|
||||
# hostname from that single source of truth. httpRoute.hostnames stays
|
||||
# empty on purpose: the fallback is the path every installation will
|
||||
# actually use.
|
||||
# - ingress.nodeGroup comes from the fixture too, so the route is checked to
|
||||
# point at THAT GROUP'S http Service and no other. This chart is the one in
|
||||
# group 3 where the backend is group-scoped, and the ONE-route-not-one-
|
||||
# per-group property is exactly what would be easy to get wrong on a copy.
|
||||
# - the backendRef is checked against the per-group http Service of the same
|
||||
# render, by PORT NUMBER (ports.http.containerPort) rather than by the name
|
||||
# "http" the Ingress uses, because backendRefs takes a PortNumber. Note
|
||||
# that this chart's NodePort Services publish a DIFFERENT number
|
||||
# (ports.<name>.nodePort) than the container binds, so naming the wrong one
|
||||
# here would be a plausible mistake - this overlay is what catches it.
|
||||
#
|
||||
# No URLRewrite filter is rendered by this chart in either mode, so the #118
|
||||
# prefix/redirect assurance has no precondition to meet here.
|
||||
#
|
||||
# NOT covered here, and none of it can be:
|
||||
#
|
||||
# - that the hostname answers. No Gateway controller runs in CI and no route
|
||||
# reaches "Accepted: True".
|
||||
# - THE CORS CONFIGURATION, which is the thing this chart actually loses in
|
||||
# this mode and the one that breaks the bulwark webmail. It lives in the
|
||||
# operator's ingress.annotations, the chart has no values for it, and the
|
||||
# Gateway API's own CORS filter is Extended-support with observed
|
||||
# implementation variance - all of which is documented in values.yaml and
|
||||
# printed by NOTES.txt. Documentation is not something a test can assert.
|
||||
# - that SMTP/IMAP still work. They never went through the Ingress and do not
|
||||
# go through the route; the mail Certificate that serves them keeps being
|
||||
# rendered in this mode, which is this chart's distinguishing feature in
|
||||
# the sweep, and no render test looks at whether it was left alone.
|
||||
# - THE MTA-STS ROUTE (Gitea #123), which is the second HTTPRoute this chart
|
||||
# renders. Its backendRef is held against the same per-group http Service
|
||||
# by the same port number, so the one place information travels from the
|
||||
# Service object into a route template is checked twice rather than once.
|
||||
# What the list below is shaped for is the three cases the entry form has:
|
||||
# an entry WITH a clusterIssuer, an entry WITHOUT one, and a host on a
|
||||
# SUBDOMAIN - the last because mta-sts.sub.<domain> is one label deeper
|
||||
# than *.<domain> and a parent wildcard does not cover it.
|
||||
#
|
||||
# Both TLS fields are INERT IN THIS MODE: TLS sits on the Gateway's
|
||||
# listener and this chart renders no certificate for an HTTP hostname
|
||||
# here. They are set anyway, and that is the assertion - the render has to
|
||||
# ACCEPT them in Gateway mode without requiring them, because the mirror
|
||||
# image (tlsSecretName required in Ingress mode) is what makes them
|
||||
# mode-specific rather than optional-everywhere.
|
||||
#
|
||||
# The Ingress half of this feature is NOT exercised here - this overlay's
|
||||
# whole job is to switch the mode off. It is exercised by
|
||||
# ci/stalwart/test-values.yaml instead, which installs in Ingress mode and
|
||||
# therefore has the MTA-STS Ingress objects accepted by a real API server.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
mtaSts:
|
||||
hosts:
|
||||
- host: mta-sts.ci.example.com
|
||||
tlsSecretName: tls-mta-sts-ci-example-com-ingress
|
||||
clusterIssuer: ci-selfsigned
|
||||
# One label deeper: its own secret, because the parent's wildcard would not
|
||||
# reach it. In this mode that is the Gateway listener's problem, which is
|
||||
# exactly why the entry is here and why the values are inert.
|
||||
- host: mta-sts.sub.ci.example.com
|
||||
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
|
||||
clusterIssuer: ci-selfsigned
|
||||
# No clusterIssuer: "the certificate comes from somewhere else". Nothing in
|
||||
# this mode reads it either way; the point is that the render accepts the
|
||||
# entry without it.
|
||||
- host: mta-sts.other.ci.example
|
||||
tlsSecretName: tls-some-existing-wildcard-ingress
|
||||
@@ -1,33 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/sure/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true. For THIS chart the combination also exercises
|
||||
# sure.publishedOverTls: with the Ingress off and the route on, the render must
|
||||
# still produce RAILS_ASSUME_SSL/RAILS_FORCE_SSL as "true", because TLS is
|
||||
# terminated at the Gateway. That is not asserted by this test - it only looks
|
||||
# at HTTPRoutes - but it is rendered here, so a regression shows up in the
|
||||
# manifest this job parses rather than only in a cluster.
|
||||
#
|
||||
# ingress.domain comes from ci/sure/test-values.yaml and keeps feeding
|
||||
# APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS in this mode too;
|
||||
# httpRoute.hostnames is left empty on purpose, so the fallback to
|
||||
# ingress.domain is what the test exercises.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,23 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# template-chart has no ci/<chart>/test-values.yaml (it is excluded from the
|
||||
# install-test for its placeholder image), so the test reaches it through
|
||||
# ci/trivy/values/template-chart.yaml, which this overlay sits on top of.
|
||||
#
|
||||
# ingress.enabled: false is not optional here. The two modes are mutually
|
||||
# exclusive and the chart aborts its own render when both are true, so an
|
||||
# overlay that only switched httpRoute on would turn this test red on the
|
||||
# charts that implement the convention correctly.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,28 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/urlaubsverwaltung/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart and the two modes are
|
||||
# mutually exclusive, so switching the fixture would drop this chart's Ingress
|
||||
# from the install for a CRD schema cyberchef's fixture already exercises
|
||||
# against the real Gateway-API CRDs.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/urlaubsverwaltung/test-values.yaml. This is the
|
||||
# one chart in the group where nothing but the routing templates reads it, so
|
||||
# with the overlay applied the HTTPRoute's required() is the only thing
|
||||
# demanding it - which is exactly the path worth rendering.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,29 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/voucher-vault/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart and the two modes are
|
||||
# mutually exclusive, so switching the fixture would drop this chart's Ingress
|
||||
# from the install for a CRD schema cyberchef's fixture already exercises
|
||||
# against the real Gateway-API CRDs.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/voucher-vault/test-values.yaml and keeps feeding
|
||||
# Django's DOMAIN (and through it ALLOWED_HOSTS) in this mode too;
|
||||
# httpRoute.hostnames is left empty on purpose, so the fallback to
|
||||
# ingress.domain is what the test exercises - and the two therefore cannot
|
||||
# disagree here, which is the one way this chart fails invisibly.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,64 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/ytdlp-web-player/test-values.yaml: the
|
||||
# install-test runs exactly ONE configuration per chart, and this chart's
|
||||
# fixture is the one that resolves and installs the oauth2-proxy SUBCHART.
|
||||
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
|
||||
# performs against the real Gateway-API CRDs would be a bad swap. The install
|
||||
# keeps the Ingress; the route is held here, at render level.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - ingress.enabled defaults to
|
||||
# TRUE in this chart, so without this line the chart aborts its own render with
|
||||
# both modes on.
|
||||
#
|
||||
# THE SUBCHART IS LEFT DISABLED, as the fixture has it, and that is deliberate
|
||||
# rather than lazy. The oauth2-proxy chart HAS NO GATEWAY-API SUPPORT - it
|
||||
# renders an Ingress or nothing - so switching it on here would add a second
|
||||
# object this test does not look at, while the thing worth exercising in
|
||||
# Gateway mode is this chart's own route. The interesting interaction between
|
||||
# the two (one hostname claimed by the proxy's Ingress AND by this chart's
|
||||
# HTTPRoute, which the ingress-nginx admission webhook cannot catch because the
|
||||
# kinds differ) is a RENDER-FAILURE path: the chart refuses it. This test only
|
||||
# runs renders it expects to succeed, so that guard was exercised by hand
|
||||
# instead - see the PR.
|
||||
#
|
||||
# WHAT THIS OVERLAY COVERS:
|
||||
#
|
||||
# - ingress.domain comes from the fixture (ytdlp-web-player.example.com), so
|
||||
# the route renders with its hostname from that single source of truth.
|
||||
# httpRoute.hostnames stays empty on purpose: the fallback is the path an
|
||||
# installation will actually use.
|
||||
# - the backendRef is checked against the application Service of the same
|
||||
# render, by PORT NUMBER rather than by the name "http" the Ingress uses,
|
||||
# because backendRefs takes a PortNumber. That number also has to agree
|
||||
# with ytdlp-web-player.appServiceAddress, which is what the proxy gets as
|
||||
# its upstream - three places, and this overlay holds two of them together.
|
||||
#
|
||||
# No URLRewrite filter is rendered by this chart in either mode, so the #118
|
||||
# prefix/redirect assurance has no precondition to meet here.
|
||||
#
|
||||
# NOT covered here, and it cannot be:
|
||||
#
|
||||
# - that the hostname answers. No Gateway controller runs in CI and no route
|
||||
# reaches "Accepted: True".
|
||||
# - that authentication is in front of the application. With the proxy off
|
||||
# this route IS the unauthenticated path, which is correct for a fixture
|
||||
# and is exactly what NOTES.txt warns about for a real installation.
|
||||
# - anything about how this release is published in the real cluster. There
|
||||
# is no live Ingress of this chart to compare against: measured 2026-10-12,
|
||||
# the release runs in namespace jlab-player with both Deployments Ready and
|
||||
# not one of the cluster's 23 Ingress objects is in that namespace. What
|
||||
# publishes it could not be determined from the workspace.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,29 +0,0 @@
|
||||
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
|
||||
#
|
||||
# Its one job is to put this chart into Gateway mode, because the convention
|
||||
# ships with httpRoute.enabled: false and the test would otherwise be green
|
||||
# over nothing. A chart that has an overlay here and still renders no
|
||||
# HTTPRoute FAILS the test - that is the whole point of the file existing.
|
||||
#
|
||||
# An overlay and not a change to ci/zipline/test-values.yaml: the install-test
|
||||
# runs exactly ONE configuration per chart and the two modes are mutually
|
||||
# exclusive, so switching the fixture would drop this chart's Ingress from the
|
||||
# install for a CRD schema cyberchef's fixture already exercises against the
|
||||
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
|
||||
# at render level, which is where its two silent failure modes live.
|
||||
#
|
||||
# ingress.enabled: false is not optional here - the chart aborts its own render
|
||||
# when both modes are true.
|
||||
#
|
||||
# ingress.domain comes from ci/zipline/test-values.yaml and keeps feeding
|
||||
# CORE_DEFAULT_DOMAIN in this mode too; httpRoute.hostnames is left empty on
|
||||
# purpose, so the fallback to ingress.domain is what the test exercises - and
|
||||
# the two therefore cannot disagree here.
|
||||
ingress:
|
||||
enabled: false
|
||||
httpRoute:
|
||||
enabled: true
|
||||
parentRefs:
|
||||
- name: public
|
||||
namespace: gateway
|
||||
sectionName: https
|
||||
@@ -1,118 +0,0 @@
|
||||
# Fixtures for the keycloak install-test: a throwaway PostgreSQL plus the two
|
||||
# Secrets the 1Password operator would otherwise materialize from the
|
||||
# OnePasswordItems the chart renders (release name: ci, stub CRD in ci/common/).
|
||||
#
|
||||
# WHY A REAL DATABASE AND NOT A STUB: Keycloak does nothing without one. It
|
||||
# creates its entire schema through Liquibase on first start, and - the reason
|
||||
# this fixture is the valuable half of the test - jdbc-ping discovers the second
|
||||
# replica THROUGH this database. A stub would turn a cluster test into a
|
||||
# templating test.
|
||||
#
|
||||
# The image is pinned to a major line like every other fixture here
|
||||
# (postgres:16-alpine); ci/** is excluded from Renovate on purpose, because
|
||||
# this is test scaffolding and churning it only risks breaking the
|
||||
# install-test.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-keycloak-db-secret
|
||||
stringData:
|
||||
# The field NAMES are what matters here, not the values: the operator
|
||||
# materializes one Secret key per item field, and the Deployment references
|
||||
# these two by name. A rename on either side leaves the pod in
|
||||
# CreateContainerConfigError, which is a state no render can show.
|
||||
#
|
||||
# They must agree with the POSTGRES_USER / POSTGRES_PASSWORD below, because
|
||||
# Keycloak really connects.
|
||||
username: keycloak
|
||||
password: ci-keycloak-db-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-keycloak-bootstrap-admin-secret
|
||||
stringData:
|
||||
# The temporary bootstrap administrator. Keycloak creates it while the master
|
||||
# realm is created, so these values are exercised on the very first start -
|
||||
# and the variables are named KC_BOOTSTRAP_ADMIN_* only since Keycloak 26.
|
||||
#
|
||||
# A visibly throwaway password: nothing here survives the run, the cluster is
|
||||
# deleted with the job, and the only thing it protects is a realm that exists
|
||||
# for a few minutes.
|
||||
username: ci-temp-admin
|
||||
password: ci-bootstrap-admin-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: public.ecr.aws/docker/library/postgres:16-alpine
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: keycloak
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: ci-keycloak-db-password
|
||||
- name: POSTGRES_DB
|
||||
value: keycloak
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "keycloak"]
|
||||
periodSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
# emptyDir: the database exists for the length of one job. Nothing here
|
||||
# is meant to survive, and a PVC would only add a provisioner to the
|
||||
# list of things that can fail a chart's test.
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
# A MINIMAL BUT REAL LOGIN THEME, so that the install-test actually walks the
|
||||
# path keycloak.extraVolumes exists for (Gitea #88) instead of only rendering
|
||||
# it. ci/keycloak/test-values.yaml mounts it on the SUBPATH
|
||||
# /opt/keycloak/themes/ci-theme - never on /opt/keycloak/themes itself, which
|
||||
# would hide the image's base, keycloak and keycloak.v2 themes.
|
||||
name: ci-keycloak-theme
|
||||
data:
|
||||
# A theme's layout is <theme>/<type>/theme.properties, and a ConfigMap key
|
||||
# cannot contain a slash - so the "login/" directory is produced by
|
||||
# items[].path in the volume, which can. That indirection is exactly the part
|
||||
# of the theme route a render cannot verify and a running pod can.
|
||||
theme.properties: |
|
||||
parent=keycloak.v2
|
||||
# The reason the issue was opened: Keycloak has no logo field on an identity
|
||||
# provider, so the icon on its login button comes only from a login theme.
|
||||
# Nothing in CI drives a browser, so this line is carried rather than
|
||||
# checked - what the run proves is that the theme reaches the container.
|
||||
kcLogoIdP-ci-provider = fa fa-lock
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
@@ -1,17 +0,0 @@
|
||||
# The default 300s is not enough here, and the three parts that fill the budget
|
||||
# are worth naming so the number can be lowered again if one of them changes:
|
||||
#
|
||||
# 1. TWO INIT CONTAINERS PER POD. The build one runs a full Quarkus
|
||||
# augmentation - upstream's own issue reports measure "Quarkus
|
||||
# augmentation completed in" at roughly 13-23 seconds, and a CI runner is
|
||||
# not a fast machine.
|
||||
# 2. THE FIRST START CREATES THE WHOLE SCHEMA through Liquibase, and with two
|
||||
# replicas the second pod waits on the first's migration lock. So the two
|
||||
# pods do not become Ready in parallel.
|
||||
# 3. The rollout is only complete when BOTH pods are Ready, which is
|
||||
# deliberate (it is what makes cluster formation part of the assertion)
|
||||
# and which means the slow parts add up rather than overlap.
|
||||
#
|
||||
# The chart's own startupProbe budget is 600s (10s x 60); this timeout has to
|
||||
# be at least that, or the test gives up before the probe would have.
|
||||
INSTALL_TIMEOUT=900
|
||||
@@ -1,259 +0,0 @@
|
||||
# CI values for the keycloak install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth.
|
||||
# Keycloak has no volume, needs one throwaway PostgreSQL and reaches Ready on
|
||||
# its own, so an exclusion would be hard to justify. More importantly, this is
|
||||
# the only place where the four riskiest assumptions of the chart are exercised
|
||||
# at all - none of them is observable in a render.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES, point by point (the measurement points of Gitea #83):
|
||||
#
|
||||
# 1. THE TWO INIT CONTAINERS WORK AT ALL. The seed container copies the
|
||||
# image's own augmentation output into the volume, the build container
|
||||
# mounts that volume at /opt/keycloak/lib/quarkus and runs `kc.sh build`
|
||||
# into it - both with readOnlyRootFilesystem: true. If the seeding
|
||||
# assumption were wrong (QuarkusEntryPoint loads
|
||||
# quarkus/quarkus-application.dat before it dispatches any command,
|
||||
# `build` included), the build container could not start, and the pod
|
||||
# would sit in Init with the reason only in its log.
|
||||
# 2. readOnlyRootFilesystem: true HOLDS FOR THE SERVING CONTAINER, with
|
||||
# emptyDirs only at /opt/keycloak/lib/quarkus, /opt/keycloak/data and
|
||||
# /tmp - and NOT at /opt/keycloak/conf, which would hide the
|
||||
# cache-ispn.xml that cache=ispn reads.
|
||||
# 3. `start --optimized` ACCEPTS THE PRE-BUILT INSTALLATION and does not
|
||||
# re-augment. A disagreement between the build options and the serving
|
||||
# container would show up here as a crash loop rather than as a slow
|
||||
# start, because the path it would want to rewrite is read-only.
|
||||
# 4. TWO REPLICAS BOTH COME UP AND STAY UP against one database - the
|
||||
# rollout is complete only when BOTH pods are Ready, so a second replica
|
||||
# that crashed, deadlocked on the schema migration lock or fought the
|
||||
# first over the database would fail the run. Measured in Gitea PR #87:
|
||||
# 71.8s from `helm install` to both Ready, 0 restarts - three runs, all
|
||||
# three 72s and 0 restarts.
|
||||
# THAT WAS LUCK, OR A FAST WEEK, AND NOT AN INVARIANT - which took an A/B
|
||||
# inside Gitea PR #92 to establish, because the first five runs there made
|
||||
# it look like the new theme mount had destabilized the start:
|
||||
# WITH the theme mount (spec of #88)
|
||||
# run 585: 101s, ONE restart on one pod
|
||||
# run 590: 82s, 0
|
||||
# run 593: 191s, 0
|
||||
# run 595: 101s, ONE
|
||||
# run 597: 111s, ONE -> 3 of 5
|
||||
# WITHOUT it (the same branch, the pair commented out; pod template
|
||||
# hash 5844db56b6, i.e. BYTE-IDENTICAL to the spec PR #87 measured)
|
||||
# run 598: 81s, 0
|
||||
# run 599: 171s, ONE -> 1 of 2
|
||||
# So the restart happens WITHOUT the mount as well, and #88 is not its
|
||||
# cause. It is pre-existing, intermittent behaviour of THIS chart's first
|
||||
# install: always exactly one restart on one pod, 35-80s before that pod
|
||||
# reports Ready. It does not track the runner's speed either - the fastest
|
||||
# (81s) and the slowest (191s) run of the whole series restarted nothing.
|
||||
# WHAT THE RESTART IS HAS NOT BEEN ESTABLISHED, and that is the finding
|
||||
# worth keeping: the harness dumps container logs only when a run FAILS
|
||||
# (diagnose() hangs off the EXIT trap behind "RC -ne 0") and all seven runs
|
||||
# PASSED - A GREEN RUN CANNOT EXPLAIN A RESTART IT REPORTS. Answering it
|
||||
# means making the final-state step print the restart reasons whenever a
|
||||
# counter is above zero; that is a change to ci/run-install-test.sh with a
|
||||
# CI-CD wiki obligation of its own, and it was deliberately NOT made inside
|
||||
# #88. A first install that serializes two replicas on the Liquibase
|
||||
# migration lock is a plausible place for it - stated as the hypothesis it
|
||||
# is, because nothing here measured it.
|
||||
# THE METHOD IS THE PART TO REUSE: a counter that moved in the same round
|
||||
# as a change is not evidence against the change until the control case has
|
||||
# run on the SAME machine. Three clean runs two days earlier were not that
|
||||
# control, and treating them as one would have cost this PR its conclusion
|
||||
# in both directions.
|
||||
# 5. A MOUNTED LOGIN THEME SURVIVES ALL OF THAT (Gitea #88) - the full
|
||||
# argument sits at keycloak.extraVolumes further down, including what it
|
||||
# still does not prove.
|
||||
# Plus the mechanical half: the Secret key names match what the Deployment
|
||||
# references, /health/started, /health/live and /health/ready all answer on
|
||||
# the management port (so health-enabled really was baked in by the init
|
||||
# container), the resource quantities are valid, and the schema accepts a
|
||||
# realistic non-default values file.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and the first entry is the one not to misread:
|
||||
# - THAT jdbc-ping ACTUALLY FOUND THE PEER. Two replicas both reaching Ready
|
||||
# is NOT that proof, and believing it was is the mistake this paragraph
|
||||
# exists to prevent: readiness reports this node's own health, so a node
|
||||
# that never found its peer is Ready, healthy and alone - and two such
|
||||
# nodes are two islands that each believe they are the cluster. The
|
||||
# Infinispan view has to be read out of the log, which is what step 2 of
|
||||
# NOTES.txt tells the operator to do. So measurement point 4 of the plan
|
||||
# stays open after this test; what the test does rule out is the louder
|
||||
# half (a second replica that cannot start or cannot share the database).
|
||||
# - that hostname-admin serves the console cleanly on a second hostname. No
|
||||
# ingress controller answers in CI and nothing drives a browser, so
|
||||
# measurement point 5 stays open too. What IS checked is that both Ingress
|
||||
# objects are accepted, that the Service port they name exists, and that
|
||||
# Keycloak accepts both hostname values as full URLs at start.
|
||||
# - anything about an upgrade. The rolling-update restriction between minor
|
||||
# versions is a property of two versions meeting, and this test installs
|
||||
# one.
|
||||
# - that the PostgreSQL here resembles production. It is one pod with an
|
||||
# emptyDir, so nothing about durability, connection limits or latency
|
||||
# transfers.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/keycloak/fixtures.yaml as ci-keycloak-db-secret and
|
||||
# ci-keycloak-bootstrap-admin-secret (the OnePasswordItem stub CRD comes from
|
||||
# ci/common/).
|
||||
databaseSecretRef: op://ci/keycloak-db
|
||||
bootstrapAdminSecretRef: op://ci/keycloak-bootstrap-admin
|
||||
|
||||
keycloak:
|
||||
# TWO, i.e. the chart's default, and the whole reason this fixture is not a
|
||||
# one-replica shortcut. With one replica the install-test would be green
|
||||
# without ever showing whether the second node is found - and "a path the
|
||||
# test does not walk is a path we have not proven" is the lesson Gitea #80
|
||||
# paid for. The install-test script waits for the Deployment's rollout, and a
|
||||
# rollout is only complete when BOTH pods are Ready, so cluster formation is
|
||||
# inside the assertion rather than next to it.
|
||||
replicas: 2
|
||||
|
||||
db:
|
||||
# The throwaway PostgreSQL from fixtures.yaml. Plain Service name: both are
|
||||
# in the same namespace.
|
||||
host: postgres
|
||||
database: keycloak
|
||||
# Small but not degenerate: 2 replicas x 5 = 10 connections against
|
||||
# postgres:16-alpine's default max_connections of 100, which leaves the
|
||||
# fixture comfortable while still exercising the pool options.
|
||||
pool:
|
||||
initialSize: 1
|
||||
minSize: 1
|
||||
maxSize: 5
|
||||
|
||||
# The chart default, spelled out because it is load-bearing for what this
|
||||
# test means: metrics-enabled is what puts the DATABASE check into
|
||||
# /health/ready (upstream's check table, "Requires Metrics: Yes"). With it
|
||||
# off, readiness would pass without ever touching the database, and a green
|
||||
# run would say nothing about the connection at all.
|
||||
#
|
||||
# It is also a BUILD option, so with it true the init container has to build
|
||||
# an installation differing from the published image in three options rather
|
||||
# than two - which is what makes a build that silently did nothing fail the
|
||||
# run instead of looking identical.
|
||||
metrics:
|
||||
enabled: true
|
||||
|
||||
# Set on purpose, although one release cannot collide with itself: it
|
||||
# exercises the KC_CACHE_EMBEDDED_CLUSTER_NAME path, and a name Keycloak
|
||||
# rejected would fail the start rather than pass unnoticed.
|
||||
cache:
|
||||
clusterName: ci-keycloak
|
||||
|
||||
resources:
|
||||
# Below the chart's defaults to stay inside the runner's budget, and the
|
||||
# memory number is chosen rather than rounded: the image sizes the heap at
|
||||
# 70% of the LIMIT, so 600Mi x limitFactor 3 = 1800Mi gives a ~1260Mi heap.
|
||||
# Upstream's floor is "at least 750 MB" for the limit; going much below
|
||||
# this makes the JVM the reason a run fails.
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 600Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
build:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
# THE EXTRA VOLUME PAIR, WALKED RATHER THAN RENDERED (Gitea #88). The theme
|
||||
# ConfigMap comes from fixtures.yaml. Three things this puts inside the
|
||||
# assertion that no render reaches:
|
||||
#
|
||||
# 1. A MOUNT ON A SUBPATH OF A DIRECTORY THAT EXISTS IN THE IMAGE IS
|
||||
# ACCEPTED ALONGSIDE readOnlyRootFilesystem: true.
|
||||
# /opt/keycloak/themes/ci-theme does not exist in the image, so the
|
||||
# runtime has to create the mount point inside a root filesystem that is
|
||||
# about to be read-only. If that did not work, every container of this
|
||||
# pod would fail to start.
|
||||
# 2. `kc.sh build` STILL SUCCEEDS WITH THE EXTRA MOUNT PRESENT. The mounts
|
||||
# are rendered on the build init container too (that is what makes SPI
|
||||
# providers work at all), so this fixture is also the test that the extra
|
||||
# mount does not disturb the augmentation - a failure would leave the pod
|
||||
# in Init with the reason only in a log.
|
||||
# 3. `start --optimized` COMES UP WITH A RUNTIME THEME IN PLACE. The plan of
|
||||
# #88 called that "a reasoned expectation, not verified"; a Ready pod with
|
||||
# this mounted is the verification, and it is the one that matters for the
|
||||
# unusual two-init-container construction of this chart.
|
||||
#
|
||||
# WHAT IT STILL DOES NOT PROVE, and it would be easy to overclaim here: that
|
||||
# the LOGIN PAGE renders and that base/keycloak/keycloak.v2 are still listed.
|
||||
# The health endpoints answer regardless of the themes directory, and nothing
|
||||
# in this harness makes an HTTP request to / or runs a command in the pod. That
|
||||
# the subpath hides nothing is a property of the mount path rather than a
|
||||
# measurement - and the kcLogoIdP line needs a browser plus a realm setting,
|
||||
# neither of which exists in CI.
|
||||
extraVolumes:
|
||||
- name: login-theme
|
||||
configMap:
|
||||
name: ci-keycloak-theme
|
||||
items:
|
||||
- key: theme.properties
|
||||
path: login/theme.properties
|
||||
extraVolumeMounts:
|
||||
- name: login-theme
|
||||
# THE SUBPATH, AND THE WHOLE POINT OF THE EXAMPLE: a volume on
|
||||
# /opt/keycloak/themes would hide the image's own three themes and break
|
||||
# the login page while the pod stayed Ready.
|
||||
mountPath: /opt/keycloak/themes/ci-theme
|
||||
readOnly: true
|
||||
|
||||
# TWO REALM HOSTNAMES (Gitea #122), and they are set HERE rather than in the
|
||||
# Gateway overlay on purpose: ci/httproute/keycloak.yaml is applied on top of
|
||||
# this file, so one definition covers BOTH modes - two Ingress objects in this
|
||||
# install, two HTTPRoutes in the render-only test, from the same two entries.
|
||||
# Defining them in the overlay instead would leave the Ingress half of the
|
||||
# feature with no coverage at all, and it is the half that carries the
|
||||
# required value and the convention deviation.
|
||||
#
|
||||
# WHAT THIS INSTALL ADDS TO THE RENDER TEST, which is the part a cluster-less
|
||||
# check cannot reach: the API server accepts both objects, including the
|
||||
# Service port named BY NAME ("http") - a typo there is invisible until
|
||||
# somebody opens the URL - and the second entry's cert-manager annotation does
|
||||
# not make the apply fail in a cluster without cert-manager. No ingress
|
||||
# controller answers in CI, so neither app-root nor the proxy buffers are
|
||||
# exercised; that is the same gap the two objects above have.
|
||||
#
|
||||
# THE TWO ENTRIES ARE THE TWO FORMS, deliberately:
|
||||
# [0] NO clusterIssuer - the normal case, where the certificate comes from
|
||||
# elsewhere (a wildcard, another release) and this object only consumes
|
||||
# the secret. It also carries a per-entry annotation, which is what
|
||||
# makes the per-entry (rather than chart-global) annotations block a
|
||||
# tested shape rather than an intended one.
|
||||
# [1] WITH clusterIssuer - the case where the certificate is issued for this
|
||||
# entry's own secret.
|
||||
# Both are inert in Gateway mode, and the render test is what holds that: the
|
||||
# routes it renders from these entries must show no trace of either field.
|
||||
realmHosts:
|
||||
- host: realm-one.ci.example.com
|
||||
realm: ci-realm-one
|
||||
tlsSecretName: tls-ci-realm-shared-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
|
||||
- host: realm-two.ci.example.com
|
||||
realm: ci-realm-two
|
||||
tlsSecretName: tls-ci-realm-two-ingress
|
||||
clusterIssuer: ci-selfsigned
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that
|
||||
# both objects are accepted and that the Service port they name BY NAME
|
||||
# ("http") actually exists - a typo there is otherwise invisible until
|
||||
# somebody opens the URL.
|
||||
enabled: true
|
||||
domain: keycloak.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
admin:
|
||||
# The second hostname, on as well, so the chart renders KC_HOSTNAME_ADMIN
|
||||
# and Keycloak has to accept it as a full URL. That is the half of
|
||||
# measurement point 5 a cluster-less test can reach: Keycloak validates
|
||||
# both hostname values at start ("verified that full URL is used, incl.
|
||||
# scheme and hostname"), so a chart that built a bare host here would fail
|
||||
# the start instead of serving a broken console later.
|
||||
enabled: true
|
||||
domain: keycloak-admin.ci.example.com
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -1,587 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# HTTPRoute render test: every HTTPRoute a chart renders is held against the
|
||||
# Services of the SAME render, and against the one condition without which the
|
||||
# route attaches to nothing.
|
||||
#
|
||||
# Usage: ci/run-httproute-match-test.sh [chart ...] (no argument = all charts)
|
||||
#
|
||||
# WHAT IT GUARDS AGAINST, AND WHY NEITHER A RENDER NOR AN INSTALL DOES (#114)
|
||||
#
|
||||
# An HTTPRoute is the Gateway-API alternative to the Ingress (README, "Ingress
|
||||
# and Gateway API"), and it has two silent failure modes that look exactly like
|
||||
# a working route from the outside:
|
||||
#
|
||||
# parentRefs EMPTY - a route without a parent attaches to no Gateway. The
|
||||
# object is created, it is schema-valid, no controller rejects it, and it
|
||||
# routes nothing. There is no event and no condition; the only symptom is a
|
||||
# hostname that does not answer. The charts abort the render over this, and
|
||||
# this test is the half that catches a chart which forgot to.
|
||||
# backendRefs pointing at NOTHING - the reference is by name, in the release
|
||||
# namespace, and nothing validates that the name exists. A typo, a renamed
|
||||
# Service, or a port number that no longer matches the Service's port
|
||||
# yields a route that resolves to no endpoint. ResolvedRefs goes False in
|
||||
# the route's STATUS, which only exists once a controller has looked at it
|
||||
# - so in CI, where no Gateway controller runs, nothing reports it at all.
|
||||
# A PREFIX STRIPPED WITHOUT THE BARE PREFIX REDIRECTED (#118) - the third
|
||||
# finding, and the one that is not about an object pointing nowhere but
|
||||
# about an object pointing somewhere WRONG. A rule that matches
|
||||
# PathPrefix "<path>" (anything but "/") and carries a URLRewrite filter
|
||||
# with ReplacePrefixMatch strips the prefix before the app sees the
|
||||
# request. For the slashed form that is correct. For the BARE form -
|
||||
# "<path>", no trailing slash - the request becomes "/" and the app
|
||||
# answers 200, while the browser's document URL keeps the prefixless form:
|
||||
# every relative link in that page then resolves against the PARENT of
|
||||
# that path, which is the host root. On a shared host the root belongs to
|
||||
# a different application, so the app's own assets and XHRs are answered
|
||||
# by its neighbour. Nothing in the chart, the render, the schema or the
|
||||
# install reports any of it; the symptom is a half-working UI and a
|
||||
# neighbour in the access log, which points nowhere useful.
|
||||
#
|
||||
# So: wherever a route strips a prefix, the SAME route must also carry a
|
||||
# rule matching that path with type Exact and a RequestRedirect filter.
|
||||
# Per HTTPRoute object, because Gateway-API matching precedence is a
|
||||
# property of one route - a different route is a different hostname and
|
||||
# says nothing about this one. Exact outranks every prefix match in that
|
||||
# precedence, so the pairing is about existence and not about order.
|
||||
# freesailarr is the FIRST AND, AS OF GROUP 3 OF #114, THE ONLY chart this
|
||||
# applies to. The line that used to stand here said bulwark was the second
|
||||
# rewrite-target chart and would meet the same check when group 3 landed.
|
||||
# Group 3 landed and it does not: bulwark has ONE Ingress object, no
|
||||
# rewrite-target annotation, and cannot have one - NEXT_PUBLIC_BASE_PATH is
|
||||
# compiled into its bundle, so it cannot be served under a sub-path at all
|
||||
# and its route is one rule at "/" with no URLRewrite filter. Every
|
||||
# occurrence of the string "rewrite-target" in that chart is an explanation
|
||||
# of why it does not help, and #114's measured table arrived at "bulwark,
|
||||
# freesailarr" by grepping for the string rather than for the annotation.
|
||||
# The check stays over all charts for the reason below - the mistake
|
||||
# arrives by copy - not because a second chart is known to need it.
|
||||
#
|
||||
# And the install-test cannot close either gap, even with the real CRDs in the
|
||||
# cluster: schema validation accepts an empty parentRefs list and accepts a
|
||||
# backendRef to a Service that does not exist. The CRDs prove the object is
|
||||
# WELL-FORMED; this test is what proves it POINTS SOMEWHERE.
|
||||
#
|
||||
# Over ALL charts and not the changed ones, for the same reason as
|
||||
# ci/run-quantity-render-test.sh and ci/run-selector-match-test.sh: the mistake
|
||||
# arrives by copy into a chart whose own diff nobody reads for it - and this
|
||||
# convention is going to be copied 20 more times when the sweep of #114 runs.
|
||||
# Running over everything also means there is no list to maintain: adding a
|
||||
# chart enrols it.
|
||||
#
|
||||
# HOW THE YAML IS PARSED
|
||||
#
|
||||
# By Helm, through ci/yaml2json, exactly as ci/run-selector-match-test.sh does
|
||||
# it - the runners have neither pyyaml nor yq, and a parser written in awk
|
||||
# would be able to read nothing, find no route and report no finding, leaving
|
||||
# this test green for a chart it never looked at. The only textual step is the
|
||||
# document split on a line that is exactly "---" in column one, and getting
|
||||
# that wrong is not a false pass: the fragment stops being valid YAML and helm
|
||||
# exits 1. See that script's header for the long form.
|
||||
#
|
||||
# WHAT IS DELIBERATELY NOT CHECKED, so the gaps are not mistaken for coverage
|
||||
#
|
||||
# - THAT TRAFFIC FLOWS. No Gateway controller runs in CI and none will; a
|
||||
# route's status is written by the implementation, so "Accepted: True" is
|
||||
# not obtainable here. What is obtainable: the object is schema-valid
|
||||
# (install-test, against the real CRDs) and it names a Service and a port
|
||||
# that exist (this test). Routing itself is unproven, by construction.
|
||||
# - MUTUAL EXCLUSION with the Ingress. Every chart aborts its own render when
|
||||
# ingress.enabled and httpRoute.enabled are both true, which is where that
|
||||
# check belongs: this test renders ONE configuration per chart, and a
|
||||
# render-level rule here would have to assume the switch is chart-global.
|
||||
# That holds for the pilot and may not hold for the multi-app charts of the
|
||||
# sweep (matrix-synapse renders in a loop, keycloak and bulwark have two
|
||||
# Ingresses each), so the rule is not written down here where it would have
|
||||
# to be undone.
|
||||
# - CROSS-NAMESPACE backendRefs and non-Service backends. A backendRef into
|
||||
# another namespace needs a ReferenceGrant on the other side and its target
|
||||
# is by definition not in this render; a backend that is not a Service is
|
||||
# some implementation's own kind. Both are REPORTED as "not checked" and
|
||||
# are never counted as passing, because a green tick for a code path nobody
|
||||
# entered is worse than an honest gap (README, "Two rules for every check
|
||||
# added here").
|
||||
# - A RULE WITHOUT backendRefs. Legal - a rule carrying only a
|
||||
# RequestRedirect filter needs no backend - so it is reported, not failed.
|
||||
# The redirect rule the prefix check below demands is exactly such a rule,
|
||||
# so a chart that satisfies that check also earns one "not checked" line
|
||||
# here. Both are correct: the pairing is an assertion, the missing backend
|
||||
# is an honest gap.
|
||||
# - THAT ANY REDIRECT IS EVER SERVED. The prefix/redirect pairing proves the
|
||||
# rendered OBJECT carries a RequestRedirect rule on the bare prefix. It
|
||||
# does not prove that a 301 ever reaches a client, and it cannot: no
|
||||
# Gateway controller runs in CI, nothing evaluates matching precedence,
|
||||
# and no route reaches "Accepted: True" - so whether the implementation
|
||||
# honours the statusCode, or declines it and fails the whole route, is
|
||||
# outside what this test can see. What is proven is that the rule exists,
|
||||
# that it matches the right path, and that the chart did not silently stop
|
||||
# rendering it.
|
||||
#
|
||||
# AND THE ANTI-VACUUM GUARDS, because this test can be green over nothing. The
|
||||
# convention ships with httpRoute.enabled: false, so a chart renders no route
|
||||
# at all unless its CI values or an overlay switch the mode on. Two rules:
|
||||
#
|
||||
# 1. ci/httproute/<chart>.yaml is an overlay applied last, whose only job is
|
||||
# to put a chart into Gateway mode for this test. A chart that HAS one and
|
||||
# still renders no HTTPRoute FAILS - the overlay exists for exactly that.
|
||||
# 2. If no HTTPRoute at all was checked across the whole run, the run fails.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CI_DIR="$ROOT/ci"
|
||||
YAML2JSON="$CI_DIR/yaml2json"
|
||||
OVERLAY_DIR="$CI_DIR/httproute"
|
||||
RELEASE="httproute"
|
||||
|
||||
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=""
|
||||
WITH_GAPS=""
|
||||
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
|
||||
|
||||
# Same reason as in the other two render tests: 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 - so without this line a PR
|
||||
# touching nothing but a README would go red on the charts that have one.
|
||||
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||
|
||||
# Values: the same precedence the other render tests use, so a chart's CI
|
||||
# values are maintained in one place and satisfy its required() values.
|
||||
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/httproute/$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=httproute $CHART::$CHART does not render with its CI values, so no HTTPRoute could be checked. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")"
|
||||
FAILED="$FAILED $CHART"
|
||||
continue
|
||||
fi
|
||||
|
||||
# --- The document split, and the only textual step in this script ----------
|
||||
# One YAML sequence item per rendered document, content indented by four and
|
||||
# the item marker on a line of its own - so a document whose first line is a
|
||||
# comment ("# Source: ...") needs no special case. A wrong split does not
|
||||
# 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=httproute $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not an HTTPRoute finding: either the render contains a document this script's '---' split cut in the wrong place, or the manifest is not valid YAML. Helm's own message is in the job log."
|
||||
FAILED="$FAILED $CHART"
|
||||
continue
|
||||
fi
|
||||
if [ ! -s "$JSON" ]; then
|
||||
echo "::endgroup::"
|
||||
echo "::error title=httproute $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 --------------------------------------------------------
|
||||
# Inline and on the standard library only, as in the two sibling tests. It
|
||||
# writes one "VERDICT|detail" line per assertion and never decides the exit
|
||||
# code itself: the loop below does, 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 []
|
||||
docs = [d for d in docs if isinstance(d, dict)]
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# Every Service of the render, as name -> the set of port NUMBERS it exposes.
|
||||
# Port numbers and not names on purpose: an HTTPRoute backendRef carries a
|
||||
# PortNumber and cannot reference a port by name, which is exactly where it
|
||||
# differs from the Ingress backend this convention replaces - and exactly why
|
||||
# a chart has to keep the number in the route in step with its Service by
|
||||
# hand.
|
||||
services = {}
|
||||
for doc in docs:
|
||||
if doc.get("kind") != "Service":
|
||||
continue
|
||||
ports = set()
|
||||
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
|
||||
for port in (spec.get("ports") or []):
|
||||
if isinstance(port, dict) and port.get("port") is not None:
|
||||
ports.add(str(port["port"]))
|
||||
services[name_of(doc)] = ports
|
||||
|
||||
routes = [d for d in docs if d.get("kind") == "HTTPRoute"]
|
||||
lines = []
|
||||
|
||||
for doc in routes:
|
||||
route = "HTTPRoute %s" % name_of(doc)
|
||||
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
|
||||
|
||||
# 1. parentRefs must be a non-empty list. The whole point: a route without
|
||||
# a parent is valid, created, and inert.
|
||||
parents = spec.get("parentRefs")
|
||||
if not isinstance(parents, list) or not parents:
|
||||
lines.append("FAIL|%s: %s has %s parentRefs. A route without a parent "
|
||||
"attaches to NO Gateway: the object is created, it is "
|
||||
"schema-valid, no controller rejects it and it routes "
|
||||
"nothing - no event, no condition, the only symptom is a "
|
||||
"hostname that does not answer. The chart must abort its "
|
||||
"own render on an empty httpRoute.parentRefs"
|
||||
% (chart, route,
|
||||
"no" if parents is None else "an empty list of"))
|
||||
else:
|
||||
bad = [p for p in parents
|
||||
if not isinstance(p, dict) or not str(p.get("name") or "").strip()]
|
||||
if bad:
|
||||
lines.append("FAIL|%s: %s has %d parentRef(s) without a usable "
|
||||
"'name' (%r). ParentReference.name is required by the "
|
||||
"API; a reference without it attaches to nothing"
|
||||
% (chart, route, len(bad), bad))
|
||||
else:
|
||||
lines.append("OK|%s: %s has %d parentRef(s): %s"
|
||||
% (chart, route, len(parents),
|
||||
", ".join("%s%s" % (p["name"],
|
||||
"/%s" % p["namespace"] if p.get("namespace") else "")
|
||||
for p in parents)))
|
||||
|
||||
# 2. Every backendRefs entry, against the Services of the SAME render.
|
||||
rules = spec.get("rules")
|
||||
if not isinstance(rules, list) or not rules:
|
||||
lines.append("FAIL|%s: %s renders no rules at all, so it matches no "
|
||||
"request and forwards nothing" % (chart, route))
|
||||
continue
|
||||
|
||||
for i, rule in enumerate(rules):
|
||||
tag = "%s rules[%d]" % (route, i)
|
||||
if not isinstance(rule, dict):
|
||||
lines.append("FAIL|%s: %s is not a mapping (%r)" % (chart, tag, rule))
|
||||
continue
|
||||
backends = rule.get("backendRefs")
|
||||
if not backends:
|
||||
# Legal: a rule carrying only a RequestRedirect or a
|
||||
# RequestMirror filter needs no backend. Reported, never counted.
|
||||
lines.append("SKIP|%s: %s has no backendRefs. That is legal for a "
|
||||
"filter-only rule (a redirect, for instance) and is "
|
||||
"not evaluated here - but a rule meant to forward "
|
||||
"traffic and carrying no backend answers 500 by "
|
||||
"specification" % (chart, tag))
|
||||
continue
|
||||
if not isinstance(backends, list):
|
||||
lines.append("FAIL|%s: %s backendRefs is not a list (%r)"
|
||||
% (chart, tag, backends))
|
||||
continue
|
||||
for j, backend in enumerate(backends):
|
||||
where = "%s.backendRefs[%d]" % (tag, j)
|
||||
if not isinstance(backend, dict):
|
||||
lines.append("FAIL|%s: %s is not a mapping (%r)"
|
||||
% (chart, where, backend))
|
||||
continue
|
||||
# kind and group default to Service and the core group.
|
||||
kind = str(backend.get("kind") or "Service")
|
||||
group = str(backend.get("group") or "")
|
||||
if kind != "Service" or group not in ("", "core"):
|
||||
lines.append("SKIP|%s: %s references %s.%s, which is not a core "
|
||||
"Service - some implementation's own backend kind. "
|
||||
"This test cannot resolve it and does not count it "
|
||||
"as passing" % (chart, where, group or "core", kind))
|
||||
continue
|
||||
namespace = backend.get("namespace")
|
||||
if namespace:
|
||||
lines.append("SKIP|%s: %s references Service %s in namespace %s. A "
|
||||
"cross-namespace backend needs a ReferenceGrant on "
|
||||
"the other side and its target is by definition not "
|
||||
"in this render, so it is reported rather than "
|
||||
"checked" % (chart, where, backend.get("name"), namespace))
|
||||
continue
|
||||
target = str(backend.get("name") or "")
|
||||
if not target:
|
||||
lines.append("FAIL|%s: %s has no 'name' - it references no "
|
||||
"backend at all" % (chart, where))
|
||||
continue
|
||||
if target not in services:
|
||||
lines.append("FAIL|%s: %s references Service %r, which the SAME "
|
||||
"render does not create. A backendRef is resolved "
|
||||
"by name at runtime and validated by nothing: the "
|
||||
"route is accepted, ResolvedRefs goes False in a "
|
||||
"status no controller writes in CI, and the "
|
||||
"hostname answers with the Gateway's error instead "
|
||||
"of the app. Services this render does create: %s"
|
||||
% (chart, where, target,
|
||||
", ".join(sorted(services)) or "none at all"))
|
||||
continue
|
||||
port = backend.get("port")
|
||||
if port is None:
|
||||
lines.append("FAIL|%s: %s references Service %r without a port. "
|
||||
"A backendRef to a Service must carry one - the "
|
||||
"CRD schema cannot require it conditionally, so an "
|
||||
"omitted port is accepted at apply time and "
|
||||
"resolves to nothing. The Service exposes: %s"
|
||||
% (chart, where, target,
|
||||
", ".join(sorted(services[target])) or "no ports"))
|
||||
continue
|
||||
if str(port) not in services[target]:
|
||||
lines.append("FAIL|%s: %s references Service %r on port %s, which "
|
||||
"that Service does not expose (it serves %s). "
|
||||
"backendRefs takes a port NUMBER and cannot name a "
|
||||
"port, so this pair is kept in step by hand and "
|
||||
"drifts the moment one side is edited alone"
|
||||
% (chart, where, target, port,
|
||||
", ".join(sorted(services[target])) or "no ports"))
|
||||
continue
|
||||
lines.append("OK|%s: %s resolves to Service %s:%s, created by the same "
|
||||
"render" % (chart, where, target, port))
|
||||
|
||||
# 3. A stripped prefix needs the BARE prefix redirected, in THIS route
|
||||
# (#118). Collected over all rules of this one object first, because the
|
||||
# pairing is a property of the route and not of a rule: the Gateway API
|
||||
# ranks an Exact path match above every prefix match, so the redirect
|
||||
# rule may sit anywhere in the list and still win.
|
||||
stripped = {} # prefix -> index of the rule that strips it
|
||||
redirected = {} # path -> index of the rule that redirects it exactly
|
||||
for i, rule in enumerate(rules):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
filter_types = set()
|
||||
strips_prefix = False
|
||||
filters = rule.get("filters")
|
||||
for f in (filters if isinstance(filters, list) else []):
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
ftype = str(f.get("type") or "")
|
||||
filter_types.add(ftype)
|
||||
if ftype != "URLRewrite":
|
||||
continue
|
||||
rewrite = f.get("urlRewrite")
|
||||
rewrite = rewrite if isinstance(rewrite, dict) else {}
|
||||
rpath = rewrite.get("path")
|
||||
rpath = rpath if isinstance(rpath, dict) else {}
|
||||
if str(rpath.get("type") or "") == "ReplacePrefixMatch":
|
||||
strips_prefix = True
|
||||
matches = rule.get("matches")
|
||||
for m in (matches if isinstance(matches, list) else []):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
mpath = m.get("path")
|
||||
mpath = mpath if isinstance(mpath, dict) else {}
|
||||
# The API's own defaults for HTTPPathMatch.
|
||||
ptype = str(mpath.get("type") or "PathPrefix")
|
||||
value = str(mpath.get("value") or "/")
|
||||
if strips_prefix and ptype == "PathPrefix" and value != "/":
|
||||
stripped.setdefault(value, i)
|
||||
if ptype == "Exact" and "RequestRedirect" in filter_types:
|
||||
redirected.setdefault(value, i)
|
||||
|
||||
for prefix in sorted(stripped):
|
||||
if prefix in redirected:
|
||||
lines.append("OK|%s: %s strips the prefix %r (rules[%d]) and "
|
||||
"redirects the bare, slashless %r to the slashed form "
|
||||
"(rules[%d]) - so the prefixless entry point cannot be "
|
||||
"answered under a URL whose relative links leave the app"
|
||||
% (chart, route, prefix, stripped[prefix], prefix,
|
||||
redirected[prefix]))
|
||||
else:
|
||||
lines.append("FAIL|%s: %s rules[%d] strips the prefix %r with a "
|
||||
"ReplacePrefixMatch rewrite, but NO rule of this route "
|
||||
"redirects the bare prefix: there is no match of type "
|
||||
"Exact on %r carrying a RequestRedirect filter. A "
|
||||
"request for %r WITHOUT the trailing slash is then "
|
||||
"rewritten to \"/\" and the app answers it 200, so "
|
||||
"nothing anywhere reports a problem - but the browser's "
|
||||
"document URL stays prefixless, every relative link in "
|
||||
"that page resolves against the host ROOT, and on a "
|
||||
"shared host the root is a DIFFERENT application, which "
|
||||
"then answers the app's own assets and XHRs. The fix is "
|
||||
"a rule matching %r exactly with a RequestRedirect to "
|
||||
"'%s/' and statusCode 301 written out (302 is the API's "
|
||||
"default, and 308 is Extended - an implementation that "
|
||||
"declines it sets Accepted=False on the WHOLE route, "
|
||||
"taking every other app on it down). Exact outranks "
|
||||
"every prefix match, so where the rule sits in the list "
|
||||
"does not matter. Gitea #118"
|
||||
% (chart, route, stripped[prefix], prefix, prefix,
|
||||
prefix, prefix, prefix))
|
||||
|
||||
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=httproute $CHART::the HTTPRoute 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
|
||||
CHART_SKIP=0
|
||||
while IFS='|' read -r VERDICT DETAIL; do
|
||||
case "$VERDICT" in
|
||||
OK)
|
||||
CHART_OK=$((CHART_OK + 1))
|
||||
echo " ok $DETAIL"
|
||||
;;
|
||||
SKIP)
|
||||
CHART_SKIP=$((CHART_SKIP + 1))
|
||||
echo " gap $DETAIL"
|
||||
;;
|
||||
FAIL)
|
||||
CHART_FAIL=1
|
||||
echo " FAIL $DETAIL"
|
||||
echo "::error title=httproute $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 | $CHART_SKIP |"$'\n'
|
||||
echo "$CHART: FAILED"
|
||||
elif [ "$CHART_OK" -gt 0 ]; then
|
||||
CHECKED="$CHECKED $CHART"
|
||||
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK | $CHART_SKIP |"$'\n'
|
||||
echo "$CHART: ok ($CHART_OK assertion(s) held, $CHART_SKIP not checked)"
|
||||
elif [ -n "$HAS_OVERLAY" ]; then
|
||||
# The overlay's ONE job is to put this chart into Gateway mode. If no
|
||||
# HTTPRoute came out, this test is vacuum-green for the chart the overlay
|
||||
# was written for, which is the failure the overlay prevents.
|
||||
FAILED="$FAILED $CHART"
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | 0 | $CHART_SKIP |"$'\n'
|
||||
echo "::error title=httproute $CHART::$CHART has an overlay at ci/httproute/$CHART.yaml but rendered no HTTPRoute, so nothing was checked. That overlay exists only to switch the chart into Gateway mode - either it no longer does (a renamed value, a changed gate) or the chart stopped rendering a route. A green tick here would be a tick for a check that ran over nothing."
|
||||
echo "$CHART: FAILED (overlay present, no HTTPRoute rendered)"
|
||||
else
|
||||
NOT_EXERCISED="$NOT_EXERCISED $CHART"
|
||||
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
|
||||
echo "$CHART: no HTTPRoute 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 "### HTTPRoute render test (Gitea #114)"
|
||||
echo
|
||||
echo "Every \`HTTPRoute\` a chart renders, held against the \`Service\` objects of the"
|
||||
echo "**same** render. An empty \`parentRefs\` attaches the route to no Gateway, and a"
|
||||
echo "\`backendRefs\` entry naming a Service or a port that does not exist resolves to"
|
||||
echo "nothing — both are schema-valid, so neither the real CRDs in the install-test nor"
|
||||
echo "any render error catches them, and the only report would be a route status that no"
|
||||
echo "controller writes in CI. A third finding is not about pointing nowhere but about"
|
||||
echo "pointing somewhere wrong: a rule that strips a prefix with \`ReplacePrefixMatch\`"
|
||||
echo "while the same route carries no \`Exact\` match on that prefix with a"
|
||||
echo "\`RequestRedirect\` filter leaves the slashless form answered 200 under a URL whose"
|
||||
echo "relative links resolve to the host root — a neighbouring app (Gitea #118)."
|
||||
echo
|
||||
echo "| Result | Count | Charts |"
|
||||
echo "|---|---|---|"
|
||||
echo "| HTTPRoutes checked | $(count "$CHECKED") | $(listing "$CHECKED") |"
|
||||
echo "| No HTTPRoute rendered | $(count "$NOT_EXERCISED") | $(listing "$NOT_EXERCISED") |"
|
||||
echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |"
|
||||
echo
|
||||
echo "$TOTAL_ASSERTIONS assertion(s) held in total."
|
||||
echo
|
||||
echo "**Not provable here, by construction:** that traffic flows. No Gateway controller"
|
||||
echo "runs in CI, so no route ever reaches \`Accepted: True\`."
|
||||
if [ -n "$WITH_GAPS" ]; then
|
||||
echo
|
||||
echo "Charts with references this test does **not** resolve (cross-namespace or non-Service backends, filter-only rules — reported, never counted as passing): $(listing "$WITH_GAPS")."
|
||||
fi
|
||||
if [ -n "$SUMMARY_ROWS" ]; then
|
||||
echo
|
||||
echo "| Chart | Result | Checked | Not checked |"
|
||||
echo "| --- | --- | --- | --- |"
|
||||
printf '%s' "$SUMMARY_ROWS"
|
||||
fi
|
||||
} >>"$SUMMARY"
|
||||
|
||||
echo "====================== httproute 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 "assertions held: $TOTAL_ASSERTIONS"
|
||||
echo "=================================================================="
|
||||
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "ERROR: HTTPRoute findings in:$FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The second anti-vacuum guard. Reaching zero means every chart fell into "no
|
||||
# HTTPRoute rendered", which is indistinguishable from a broken harness - and
|
||||
# it is the state this test would silently decay into if the overlays were ever
|
||||
# dropped, or if the convention were reverted in the charts without anyone
|
||||
# removing this job.
|
||||
if [ "$TOTAL_ASSERTIONS" -eq 0 ]; then
|
||||
echo "ERROR: not a single HTTPRoute assertion was checked in this run. Either no chart renders an HTTPRoute any more, or the overlays under ci/httproute/ no longer switch one into Gateway mode. A pass over zero assertions is not a pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -8,8 +8,6 @@
|
||||
#
|
||||
# Layout (repo root):
|
||||
# ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD)
|
||||
# ci/gateway-api-crds.sh the REAL Gateway-API CRDs, applied only for a
|
||||
# chart whose render contains an HTTPRoute
|
||||
# ci/<chart>/test-values.yaml values for the CI install (required)
|
||||
# ci/<chart>/fixtures.yaml dummy secrets / throwaway infra / static PVs (optional)
|
||||
# ci/<chart>/fixtures.env.yaml fixtures with ${VARS} filled in from the environment (optional)
|
||||
@@ -201,28 +199,6 @@ echo "::endgroup::"
|
||||
# chart whose declared dependencies are not in charts/. No-op for a chart
|
||||
# without dependencies; see ci/helm-dependencies.sh.
|
||||
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||
|
||||
# --- 3a. Gateway-API CRDs, for a chart whose CI values render a route --------
|
||||
# Detected from the render rather than declared per chart in settings.env, for
|
||||
# the same reason the render tests run over every chart: there is then no list
|
||||
# to maintain, and the sweep of Gitea #114 enrols a chart by switching its
|
||||
# fixture into Gateway mode. The CRDs are REAL and not a stub - with a
|
||||
# schema-less stub the API server would accept a malformed HTTPRoute and the
|
||||
# install-test would prove only that something was created; see
|
||||
# ci/gateway-api-crds.sh for the whole argument, and for why they are not in
|
||||
# ci/common/ (which is applied client-side for every chart).
|
||||
#
|
||||
# A grep on the rendered kind, and a false negative here is loud rather than
|
||||
# silent: without the CRDs the helm install below fails with "no matches for
|
||||
# kind HTTPRoute in version gateway.networking.k8s.io/v1", which names exactly
|
||||
# what is missing.
|
||||
if helm template "$RELEASE" "$ROOT/$CHART" -f "$CI_DIR/$CHART/test-values.yaml" \
|
||||
| grep -q '^kind: HTTPRoute$'; then
|
||||
echo "::group::Gateway API CRDs"
|
||||
"$ROOT/ci/gateway-api-crds.sh"
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
echo "::group::helm install"
|
||||
helm install "$RELEASE" "$ROOT/$CHART" \
|
||||
--namespace "$NAMESPACE" \
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,522 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Selector-match render test: every label selector a chart renders is held
|
||||
# against the labels of the pod templates in the SAME render.
|
||||
#
|
||||
# Usage: ci/run-selector-match-test.sh [chart ...] (no argument = all charts)
|
||||
#
|
||||
# WHAT IT GUARDS AGAINST, AND WHY A GREEN ROLLOUT DOES NOT (Gitea #96)
|
||||
#
|
||||
# Two kinds of object in this repository select pods by label, and BOTH of them
|
||||
# fail silently when the selector matches nothing:
|
||||
#
|
||||
# topologySpreadConstraints - a constraint whose labelSelector matches no pod
|
||||
# is TRIVIALLY SATISFIED. It is a condition over zero pods, so every
|
||||
# placement meets it: nothing is distributed, the rollout is green, no event
|
||||
# is emitted and no log line appears. With whenUnsatisfiable: DoNotSchedule
|
||||
# this is worse rather than better, because the object then reads like a
|
||||
# guarantee that two replicas sit on different nodes.
|
||||
# PodDisruptionBudget - a budget whose selector matches no pod does its
|
||||
# arithmetic over zero pods and never blocks an eviction. `kubectl drain`
|
||||
# takes the whole group at once while the object sits in the namespace
|
||||
# looking like protection.
|
||||
#
|
||||
# Neither is a render error, neither is an install-test failure, and neither is
|
||||
# visible in a diff: the chart that breaks it is usually a chart that RENAMED a
|
||||
# label somewhere else. Charts keep their selector and their pod labels in one
|
||||
# helper for that reason (stalwart.groupLabels, keycloak's app label) - but one
|
||||
# definition only helps while nobody writes a second one, and that is the part a
|
||||
# test can hold.
|
||||
#
|
||||
# Over ALL charts and not the changed ones, for the same reason as
|
||||
# ci/run-quantity-render-test.sh: the mistake arrives by copy into a new chart,
|
||||
# whose own diff nobody reads for it, and running over everything means there is
|
||||
# no list to maintain - adding a chart enrols it.
|
||||
#
|
||||
# HOW THE YAML IS PARSED, AND WHY NOT WITH A PARSER WRITTEN HERE
|
||||
#
|
||||
# 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. Writing
|
||||
# a YAML reader in awk or sed was the obvious alternative and is the wrong one:
|
||||
# its failure mode is a parser that quietly reads nothing, finds no selector,
|
||||
# reports no mismatch, and leaves this test green for a chart it never looked
|
||||
# at - the exact class of signal this repository removes rather than adds.
|
||||
#
|
||||
# So HELM is the parser, which is a YAML implementation that is already
|
||||
# installed, already trusted, and already the one that produced the manifests.
|
||||
# ci/yaml2json is a one-template chart that prints its values back as JSON; the
|
||||
# rendered manifest stream is handed to it as a VALUES FILE and comes back as
|
||||
# JSON that python3 reads with its standard library. What IS done textually is
|
||||
# only the document split, on a line that is exactly "---" in column one, and
|
||||
# the consequence of getting that wrong is not a false pass: the fragment stops
|
||||
# being valid YAML and helm exits 1.
|
||||
#
|
||||
# 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.
|
||||
# - 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
|
||||
# are never counted as passing, because a green tick for a code path nobody
|
||||
# entered is worse than an honest gap (README, "Two rules for every check
|
||||
# added here").
|
||||
#
|
||||
# AND THE ANTI-VACUUM GUARDS, because this test can be green over nothing. A
|
||||
# chart renders a PDB or a spread constraint only above a replica threshold, and
|
||||
# every chart's CI fixtures sit below it. Two rules therefore apply:
|
||||
#
|
||||
# 1. ci/selector-match/<chart>.yaml is an overlay applied last, whose only job
|
||||
# is to push a chart over that threshold. A chart that HAS one and still
|
||||
# renders no selector FAILS - the overlay exists for exactly that, so an
|
||||
# overlay that stopped working is a finding and not a shrug.
|
||||
# 2. If no selector at all was checked across the whole run, the run fails.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CI_DIR="$ROOT/ci"
|
||||
YAML2JSON="$CI_DIR/yaml2json"
|
||||
OVERLAY_DIR="$CI_DIR/selector-match"
|
||||
RELEASE="selector"
|
||||
|
||||
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=""
|
||||
WITH_GAPS=""
|
||||
TOTAL_SELECTORS=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
|
||||
|
||||
# Same reason as in ci/run-quantity-render-test.sh: 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 - so without this line a PR
|
||||
# touching nothing but a README would go red on the charts that have one.
|
||||
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||
|
||||
# Values: the same precedence the other render tests use, so a chart's CI
|
||||
# values are maintained in one place and satisfy its required() values.
|
||||
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/selector-match/$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=selector-match $CHART::$CHART does not render with its CI values, so no selector could be checked. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")"
|
||||
FAILED="$FAILED $CHART"
|
||||
continue
|
||||
fi
|
||||
|
||||
# --- The document split, and the only textual step in this script ----------
|
||||
# One YAML sequence item per rendered document, content indented by four and
|
||||
# the item marker on a line of its own - so a document whose first line is a
|
||||
# comment ("# Source: ...") needs no special case. A wrong split does not
|
||||
# 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=selector-match $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not a selector finding: either the render contains a document this script's '---' split cut in the wrong place, or the manifest is not valid YAML. Helm's own message is in the job log."
|
||||
FAILED="$FAILED $CHART"
|
||||
continue
|
||||
fi
|
||||
if [ ! -s "$JSON" ]; then
|
||||
echo "::endgroup::"
|
||||
echo "::error title=selector-match $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 --------------------------------------------------------
|
||||
# Inline, as in ci/run-quantity-render-test.sh, and on the standard library
|
||||
# only - json, because the YAML is already gone by this point. It writes one
|
||||
# "VERDICT|detail" line per selector and never decides the exit code itself:
|
||||
# the loop below does, 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 []
|
||||
|
||||
# A pod template is any value of a key named "template" that carries a spec with
|
||||
# containers. Found by walking rather than by enumerating kinds, so a CronJob's
|
||||
# spec.jobTemplate.spec.template is covered without a special case, and so is
|
||||
# whatever workload kind a chart adds next.
|
||||
def pod_templates(node, path="$"):
|
||||
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 labels_of(template):
|
||||
meta = template.get("metadata")
|
||||
labels = meta.get("labels") if isinstance(meta, dict) else None
|
||||
return labels if isinstance(labels, dict) else {}
|
||||
|
||||
|
||||
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 matches(selector, labels):
|
||||
"""matchLabels only. Every key must be present with the same value; the
|
||||
comparison is on the string form, because YAML 'true' and the string
|
||||
"true" are the same label value to Kubernetes."""
|
||||
for key, value in selector.items():
|
||||
if key not in labels or str(labels[key]) != str(value):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def classify(selector, where):
|
||||
"""(matchLabels dict, None) when it can be evaluated, (None, reason) when
|
||||
it cannot. A selector this script cannot evaluate is REPORTED, never
|
||||
counted as a pass."""
|
||||
if selector is None:
|
||||
return None, ("%s has NO selector at all. For a topology spread "
|
||||
"constraint that means it matches no pod and is "
|
||||
"therefore vacuously satisfied; for a PodDisruptionBudget "
|
||||
"it is rejected by the API server" % where)
|
||||
if not isinstance(selector, dict):
|
||||
return None, "%s selector is not a mapping (%r)" % (where, selector)
|
||||
match_labels = selector.get("matchLabels")
|
||||
if isinstance(match_labels, dict) and match_labels:
|
||||
if selector.get("matchExpressions"):
|
||||
# matchLabels AND matchExpressions: the matchLabels half is still
|
||||
# a necessary condition, so checking it is sound - it just is not
|
||||
# the whole selector. Said out loud rather than passed off as full
|
||||
# coverage.
|
||||
return match_labels, None
|
||||
return match_labels, None
|
||||
if selector.get("matchExpressions"):
|
||||
return None, ("%s selects only through matchExpressions, which this "
|
||||
"test does not evaluate - doing so would mean "
|
||||
"reimplementing label-selector semantics, i.e. a second "
|
||||
"parser. Reported, not counted as passing" % where)
|
||||
if match_labels == {} or selector == {}:
|
||||
return {}, None # the empty selector matches every pod
|
||||
return None, "%s selector has neither matchLabels nor matchExpressions" % where
|
||||
|
||||
|
||||
# Rooted at "<Kind>/<name>" rather than at "$", because a chart with one
|
||||
# workload per node group renders several documents whose internal path is
|
||||
# identical - and then every finding would name the same spot.
|
||||
all_templates = []
|
||||
for doc in docs:
|
||||
if isinstance(doc, dict):
|
||||
root = "%s/%s" % (doc.get("kind", "?"), name_of(doc))
|
||||
all_templates.extend(pod_templates(doc, root))
|
||||
|
||||
lines = []
|
||||
|
||||
# 1. Every topologySpreadConstraints entry, against ITS OWN pod template. That
|
||||
# is the strict and the correct comparison: the failure being guarded
|
||||
# against is a constraint that does not count the very pods it sits on.
|
||||
for where, template in all_templates:
|
||||
spread = template.get("spec", {}).get("topologySpreadConstraints")
|
||||
if not isinstance(spread, list):
|
||||
continue
|
||||
labels = labels_of(template)
|
||||
for i, constraint in enumerate(spread):
|
||||
if not isinstance(constraint, dict):
|
||||
lines.append("FAIL|%s: topologySpreadConstraints[%d] at %s is not a mapping"
|
||||
% (chart, i, where))
|
||||
continue
|
||||
key = constraint.get("topologyKey", "<none>")
|
||||
tag = "spread[%d] on %s at %s" % (i, key, where)
|
||||
match_labels, reason = classify(constraint.get("labelSelector"), tag)
|
||||
if match_labels is None:
|
||||
if constraint.get("labelSelector") is None:
|
||||
lines.append("FAIL|%s: %s" % (chart, reason))
|
||||
else:
|
||||
lines.append("SKIP|%s: %s" % (chart, reason))
|
||||
continue
|
||||
if matches(match_labels, labels):
|
||||
lines.append("OK|%s: %s matches its own pod template's labels (%s)"
|
||||
% (chart, tag, ", ".join("%s=%s" % kv for kv in sorted(match_labels.items())) or "empty selector, matches every pod"))
|
||||
else:
|
||||
lines.append("FAIL|%s: %s selects %s but the pod template it is "
|
||||
"attached to carries %s - the constraint therefore "
|
||||
"matches NONE of its own pods, is trivially satisfied, "
|
||||
"and distributes nothing while the rollout stays green. "
|
||||
"Render the chart and compare the selector with the pod "
|
||||
"template's labels; the usual cause is a label renamed "
|
||||
"in one of the two places"
|
||||
% (chart, tag,
|
||||
", ".join("%s=%s" % kv for kv in sorted(match_labels.items())),
|
||||
", ".join("%s=%s" % kv for kv in sorted(labels.items())) or "no labels at all"))
|
||||
|
||||
# 2. Every PodDisruptionBudget, against ALL pod templates of the render: a
|
||||
# budget has no owning template, it only has to select some pod that exists.
|
||||
pdbs = [d for d in docs if isinstance(d, dict) and d.get("kind") == "PodDisruptionBudget"]
|
||||
for doc in pdbs:
|
||||
tag = "PodDisruptionBudget %s" % name_of(doc)
|
||||
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
|
||||
match_labels, reason = classify(spec.get("selector"), tag)
|
||||
if match_labels is None:
|
||||
lines.append(("FAIL|%s: %s" if spec.get("selector") is None else "SKIP|%s: %s")
|
||||
% (chart, reason))
|
||||
continue
|
||||
if not all_templates:
|
||||
lines.append("FAIL|%s: %s is rendered but this render contains no pod "
|
||||
"template at all, so its selector cannot match anything"
|
||||
% (chart, tag))
|
||||
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 PDB that matches no pod "
|
||||
"never blocks an eviction, so a node drain takes every "
|
||||
"replica at once while the object looks like protection. "
|
||||
"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"))
|
||||
|
||||
# 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
|
||||
RC=$?
|
||||
set -e
|
||||
if [ "$RC" -gt 1 ]; then
|
||||
sed 's/^/ /' "$WORK/$CHART.pyerr"
|
||||
echo "::endgroup::"
|
||||
echo "::error title=selector-match $CHART::the selector 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
|
||||
CHART_SKIP=0
|
||||
while IFS='|' read -r VERDICT DETAIL; do
|
||||
case "$VERDICT" in
|
||||
OK)
|
||||
CHART_OK=$((CHART_OK + 1))
|
||||
echo " ok $DETAIL"
|
||||
;;
|
||||
SKIP)
|
||||
CHART_SKIP=$((CHART_SKIP + 1))
|
||||
echo " gap $DETAIL"
|
||||
;;
|
||||
FAIL)
|
||||
CHART_FAIL=1
|
||||
echo " FAIL $DETAIL"
|
||||
echo "::error title=selector-match $CHART::$DETAIL"
|
||||
;;
|
||||
*)
|
||||
[ -n "$VERDICT" ] && echo " $VERDICT $DETAIL"
|
||||
;;
|
||||
esac
|
||||
done <"$WORK/$CHART.out"
|
||||
echo "::endgroup::"
|
||||
|
||||
TOTAL_SELECTORS=$((TOTAL_SELECTORS + CHART_OK))
|
||||
|
||||
if [ -n "$CHART_FAIL" ]; then
|
||||
FAILED="$FAILED $CHART"
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK | $CHART_SKIP |"$'\n'
|
||||
echo "$CHART: FAILED"
|
||||
elif [ "$CHART_OK" -gt 0 ]; then
|
||||
CHECKED="$CHECKED $CHART"
|
||||
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK | $CHART_SKIP |"$'\n'
|
||||
echo "$CHART: ok ($CHART_OK selector(s) matched, $CHART_SKIP not checked)"
|
||||
elif [ -n "$HAS_OVERLAY" ]; then
|
||||
# The overlay's ONE job is to push this chart over the threshold at which
|
||||
# it renders a selector. If it did not, this test is vacuum-green for the
|
||||
# chart it was written for, which is the failure the overlay prevents.
|
||||
FAILED="$FAILED $CHART"
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | 0 | $CHART_SKIP |"$'\n'
|
||||
echo "::error title=selector-match $CHART::$CHART has an overlay at ci/selector-match/$CHART.yaml but rendered no PodDisruptionBudget and no topologySpreadConstraints, so nothing was checked. That overlay exists only to make those objects render - either it no longer reaches the threshold (replica count, a feature switch) or the chart stopped rendering them. A green tick here would be a tick for a check that ran over nothing."
|
||||
echo "$CHART: FAILED (overlay present, no selector rendered)"
|
||||
else
|
||||
NOT_EXERCISED="$NOT_EXERCISED $CHART"
|
||||
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
|
||||
echo "$CHART: no PodDisruptionBudget and no topologySpreadConstraints 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 "### Selector-match render test (Gitea #96)"
|
||||
echo
|
||||
echo "Every \`PodDisruptionBudget\` selector and every \`topologySpreadConstraints\` label"
|
||||
echo "selector in a chart's render, held against the labels of the pod templates in the"
|
||||
echo "**same** render. A selector that matches no pod is not a render error and not an"
|
||||
echo "install-test failure: the spread constraint is then trivially satisfied and the"
|
||||
echo "budget never blocks an eviction, both silently."
|
||||
echo
|
||||
echo "| Result | Count | Charts |"
|
||||
echo "|---|---|---|"
|
||||
echo "| Selectors checked and matching | $(count "$CHECKED") | $(listing "$CHECKED") |"
|
||||
echo "| No such object rendered | $(count "$NOT_EXERCISED") | $(listing "$NOT_EXERCISED") |"
|
||||
echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |"
|
||||
echo
|
||||
echo "$TOTAL_SELECTORS selector(s) verified in total."
|
||||
if [ -n "$WITH_GAPS" ]; then
|
||||
echo
|
||||
echo "Charts with selectors this test does **not** evaluate (\`matchExpressions\` only — reported, never counted as passing): $(listing "$WITH_GAPS")."
|
||||
fi
|
||||
if [ -n "$SUMMARY_ROWS" ]; then
|
||||
echo
|
||||
echo "| Chart | Result | Checked | Not checked |"
|
||||
echo "| --- | --- | --- | --- |"
|
||||
printf '%s' "$SUMMARY_ROWS"
|
||||
fi
|
||||
} >>"$SUMMARY"
|
||||
|
||||
echo "===================== selector match test ======================"
|
||||
echo "checked: $(count "$CHECKED")${CHECKED:+ -$CHECKED}"
|
||||
echo "not exercised: $(count "$NOT_EXERCISED")${NOT_EXERCISED:+ -$NOT_EXERCISED}"
|
||||
echo "failed: $(count "$FAILED")${FAILED:+ -$FAILED}"
|
||||
echo "selectors verified: $TOTAL_SELECTORS"
|
||||
echo "================================================================"
|
||||
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "ERROR: selectors do not match the pod labels they are meant to select in:$FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The second anti-vacuum guard. Reaching zero means every chart fell into "no
|
||||
# such object rendered", which is indistinguishable from a broken harness - and
|
||||
# it is the state this test would silently decay into if the overlays were ever
|
||||
# dropped.
|
||||
if [ "$TOTAL_SELECTORS" -eq 0 ]; then
|
||||
echo "ERROR: not a single selector was checked in this run. Either no chart renders a PodDisruptionBudget or a topologySpreadConstraints any more, or the overlays under ci/selector-match/ no longer push one over its replica threshold. A pass over zero assertions is not a pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,38 +0,0 @@
|
||||
# Overlay for ci/run-selector-match-test.sh, applied ON TOP of
|
||||
# ci/stalwart/test-values.yaml and nowhere else - not by the install-test, not by
|
||||
# the Trivy scan, not by the quantity render test.
|
||||
#
|
||||
# WHY IT EXISTS. Both objects this test looks at are rendered only while a node
|
||||
# group's COMPUTED replica count is >= 2, and the install-test fixture runs
|
||||
# full: 1 / mx: 0 deliberately (a second pod would need a real in-memory store,
|
||||
# and a running "mx" pod would fail-open into a second full node against an
|
||||
# empty database). So without this file stalwart renders no PodDisruptionBudget
|
||||
# and no topologySpreadConstraints, and the test would be green for the chart it
|
||||
# was written for, having looked at nothing. ci/run-selector-match-test.sh
|
||||
# therefore FAILS a chart that has an overlay and still renders no selector.
|
||||
#
|
||||
# This is a RENDER-ONLY overlay. Nothing here is installed anywhere, so the two
|
||||
# reasons the install-test keeps mx at 0 do not apply.
|
||||
nodes:
|
||||
full:
|
||||
replicas: 2
|
||||
exposedPorts: [smtp, submissions, imaps, http]
|
||||
mx:
|
||||
# Two as well, so BOTH groups' selectors are checked. The per-group split is
|
||||
# the whole point of this chart's version of the rule: one object per group,
|
||||
# each selecting only its own pods. A single group would leave the case where
|
||||
# a group's selector accidentally matches the OTHER group's pods untested.
|
||||
replicas: 2
|
||||
exposedPorts: [smtp, http]
|
||||
|
||||
externalServices:
|
||||
valkey:
|
||||
# REQUIRED HERE, and not cosmetic: with more than one Stalwart pod and no
|
||||
# in-memory store address the chart ABORTS THE RENDER ("this release runs N
|
||||
# Stalwart pods, which is a cluster, but no in-memory store has an
|
||||
# address"). Nothing connects to this name - the render never resolves it -
|
||||
# but without it this overlay would turn the whole job red instead of
|
||||
# exercising it. valkeySubchart.enabled: true would be the other way out and
|
||||
# is deliberately not taken: it would add a network fetch of the published
|
||||
# valkey chart to a test that otherwise needs none.
|
||||
host: valkey-selector-match-fixture
|
||||
@@ -83,37 +83,6 @@ ingress:
|
||||
clusterIssuer: ci-selfsigned
|
||||
nodeGroup: full
|
||||
|
||||
# MTA-STS policy routes (Gitea #123). Set HERE and not only in the render
|
||||
# overlay, because this is the ONLY stage that puts the INGRESS half of the
|
||||
# feature in front of a real API server: ci/httproute/stalwart.yaml switches
|
||||
# the chart into Gateway mode, so it never renders these objects at all.
|
||||
#
|
||||
# WHAT THIS ADDS OVER THE RENDER TEST: that two Ingress objects whose names
|
||||
# differ only by the issuer suffix are both accepted, that a `pathType: Exact`
|
||||
# on `/.well-known/mta-sts.txt` is valid, and that a tls: list grouping several
|
||||
# hosts under one secret is accepted as written. None of that is a template
|
||||
# question any more once the API server has taken it.
|
||||
#
|
||||
# THE TWO ENTRIES ARE TWO OBJECTS, and that is the point of the pair: the first
|
||||
# carries a clusterIssuer and gets the cert-manager annotation, the second has
|
||||
# none and gets no annotation - which cannot be one object, because the
|
||||
# annotation is object-scoped. The second is also a SUBDOMAIN, one label deeper
|
||||
# than the first, carrying the case a parent wildcard certificate would not
|
||||
# cover.
|
||||
#
|
||||
# NOTHING ISSUES EITHER CERTIFICATE here: cert-manager is not installed in the
|
||||
# k3d cluster, so the annotation is inert and the named Secrets never exist.
|
||||
# That is the same deliberate trade as certificate: below - the objects are
|
||||
# asserted, the issuance is not, and no Secret is needed for the install to
|
||||
# reach Ready because nothing mounts these.
|
||||
mtaSts:
|
||||
hosts:
|
||||
- host: mta-sts.ci.example.com
|
||||
tlsSecretName: tls-mta-sts-ci-example-com-ingress
|
||||
clusterIssuer: ci-selfsigned
|
||||
- host: mta-sts.sub.ci.example.com
|
||||
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
|
||||
|
||||
certificate:
|
||||
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
|
||||
# which is the interesting case: the volume is mounted optional, so the pods
|
||||
|
||||
@@ -20,9 +20,3 @@ ksv0125:
|
||||
- cr.jk-dev.de
|
||||
- ghcr.io
|
||||
- docker.io
|
||||
# quay.io, added with the keycloak chart (Gitea #83) - Keycloak publishes
|
||||
# its official image only there (quay.io/keycloak/keycloak), and it is so
|
||||
# far the only chart in this repo using that registry. Listed rather than
|
||||
# suppressed, for the reason the comment above gives: the check keeps being
|
||||
# able to say something about an image from a genuinely foreign source.
|
||||
- quay.io
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,24 +0,0 @@
|
||||
apiVersion: v2
|
||||
# NOT A CHART THIS REPOSITORY PUBLISHES. It is a YAML parser, and Helm is the
|
||||
# only one the runners have.
|
||||
#
|
||||
# ci/run-selector-match-test.sh has to read label selectors out of rendered
|
||||
# manifests, and the runners carry neither pyyaml nor yq - measured, and the
|
||||
# same constraint ci/run-trivy-config.sh and ci/run-quantity-render-test.sh
|
||||
# work under. The alternative to this chart is a YAML parser written in awk or
|
||||
# sed, and the failure mode of that is the one this repository removes rather
|
||||
# than adds: a parser that quietly reads nothing finds no selector, reports no
|
||||
# mismatch, and the test is green for a chart it never looked at.
|
||||
#
|
||||
# So the manifest fragment is handed to Helm as a VALUES FILE, and the one
|
||||
# template below prints it back as JSON, which python3 reads with its standard
|
||||
# library. A fragment Helm cannot parse makes helm exit 1, which is loud.
|
||||
#
|
||||
# It lives under ci/ and therefore has no publish workflow, no version-bump
|
||||
# requirement and no entry in change-app-version.yml: the chart-discovery loops
|
||||
# in this repository look for a Chart.yaml one level below the repo root, and
|
||||
# "ci" has none. The version below is meaningless for the same reason.
|
||||
name: yaml2json
|
||||
description: Internal CI helper - reprints a values file as JSON so python3 can read YAML without pyyaml
|
||||
type: application
|
||||
version: 0.0.0
|
||||
@@ -1 +0,0 @@
|
||||
{{ .Values | toJson }}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Deliberately empty. Every value this chart renders comes from the -f file the
|
||||
# caller passes, which is one document out of a rendered manifest stream. A
|
||||
# default here would be merged into that document and would show up as a key
|
||||
# the chart under test never rendered.
|
||||
@@ -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.0.2+up11.4.0
|
||||
appVersion: "11.4.0"
|
||||
|
||||
maintainers:
|
||||
|
||||
+10
-113
@@ -1,14 +1,9 @@
|
||||
{{- $replicas := include "cyberchef.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.cyberchef.replicas) -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
CyberChef {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
ACCESS
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||
@@ -18,83 +13,31 @@ ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
|
||||
This chart has no secrets and no volumes — the two ingress values above are
|
||||
everything an installation needs.
|
||||
{{- else if $httpRoute.enabled }}
|
||||
|
||||
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- range $hostnames }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
|
||||
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
|
||||
of the routing path — no Gateway, no class, no Certificate. Three things
|
||||
therefore belong to the cluster and not to this chart, and if any of them is
|
||||
missing the hostname above simply does not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed, so
|
||||
they are present — nothing to check.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is
|
||||
visible only in the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is what a working attachment looks like. An empty
|
||||
status means no controller has looked at the route at all.
|
||||
3. The TLS certificate for the hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required —
|
||||
the chart renders no Certificate here, so there is no
|
||||
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
|
||||
on behalf of this release.
|
||||
|
||||
ingress.annotations is also inert in this mode: the Gateway API has no
|
||||
annotation channel for controller behaviour. See the note below on who can
|
||||
use this instance.
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so this release has no
|
||||
Ingress, no HTTPRoute and no certificate; ingress.domain and
|
||||
ingress.clusterIssuer are not needed. The app is only reachable inside the
|
||||
cluster, as {{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate;
|
||||
ingress.domain and ingress.clusterIssuer are not needed. The app is only
|
||||
reachable inside the cluster, as
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
Then browse to http://localhost:8080. To publish it instead, set either
|
||||
ingress.enabled=true with ingress.domain and ingress.clusterIssuer, or
|
||||
httpRoute.enabled=true with ingress.domain and httpRoute.parentRefs.
|
||||
Then browse to http://localhost:8080. Set ingress.enabled=true together with
|
||||
ingress.domain and ingress.clusterIssuer to publish it instead.
|
||||
{{- end }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}cyberchef.replicas: 0{{ end }} — the deployment runs with replicas: 0, so the endpoint
|
||||
above does not answer until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and
|
||||
Ingress stay in place, so the certificate is not re-issued.{{ else if $httpRoute.enabled }} and
|
||||
HTTPRoute stay in place, so the route keeps pointing at a Service with no
|
||||
endpoints — the Gateway answers with an error of its own rather than with
|
||||
nothing.{{ else }} stays in
|
||||
Ingress stay in place, so the certificate is not re-issued.{{ else }} stays in
|
||||
place.{{ end }}
|
||||
{{ end }}
|
||||
ANYONE WHO REACHES THE URL CAN USE IT
|
||||
{{- if $httpRoute.enabled }}
|
||||
CyberChef has no login, and this chart adds none. In GATEWAY MODE there is no
|
||||
place in this chart to put one either: the Gateway API has no annotation
|
||||
channel, so the ingress-nginx external-auth or basic-auth annotations that
|
||||
cover this in Ingress mode have NO counterpart here, and the chart renders no
|
||||
implementation-specific policy object on purpose (it would bind the chart to
|
||||
one Gateway implementation). Protection has to come from the Gateway itself —
|
||||
a listener that is not publicly reachable, or that implementation's own auth
|
||||
filter/policy, configured next to the Gateway and not here.
|
||||
{{- else }}
|
||||
CyberChef has no login, and this chart adds none. If it must not be open to
|
||||
everyone, put authentication in front of it via ingress.annotations — the
|
||||
chart merges them into the Ingress, e.g. ingress-nginx external auth or
|
||||
basic auth annotations. Note that this escape hatch exists in Ingress mode
|
||||
only: httpRoute.enabled has no annotation channel at all.
|
||||
{{- end }}
|
||||
basic auth annotations.
|
||||
|
||||
NOTHING TO BACK UP, NOTHING TO MIGRATE
|
||||
CyberChef is a static web app: recipes and inputs are processed in the
|
||||
@@ -108,49 +51,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,82 +0,0 @@
|
||||
{{/*
|
||||
The Gateway-API alternative to cyberchef.ingress.yaml (Gitea #114).
|
||||
|
||||
WHAT THIS RENDERS, AND WHAT IT DELIBERATELY DOES NOT
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, and no implementation-specific policy object (nginx-gateway-
|
||||
fabric's ClientSettingsPolicy, Envoy Gateway's ClientTrafficPolicy or
|
||||
whatever the next one calls it). That subset is the part which looks IDENTICAL
|
||||
on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio - and keeping to it is
|
||||
what lets the cluster's implementation be chosen, or changed, without touching
|
||||
this chart again. A policy object here would bind the chart to one
|
||||
implementation and to a CRD whose presence it cannot check.
|
||||
|
||||
So the class and the certificate move from this chart to the PLATFORM: they sit
|
||||
on the Gateway and its listeners, which belong in cluster-config, not here.
|
||||
ingress.clusterIssuer is therefore meaningless in this mode - the chart renders
|
||||
no Certificate at all - and it is not required either, because both reads of it
|
||||
live inside cyberchef.ingress.yaml, behind ingress.enabled.
|
||||
|
||||
WHY A VALUE AND NOT AUTO-DETECTION
|
||||
|
||||
.Capabilities.APIVersions is unreliable at template time (it answers from the
|
||||
cluster helm happens to be pointed at, and not at all for `helm template`),
|
||||
while an HTTPRoute without the Gateway-API CRDs fails at INSTALL time. A chart
|
||||
that guesses would therefore sometimes render an object the cluster rejects and
|
||||
sometimes silently render nothing. So the mode is a value.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block this chart dereferences (#99): an override
|
||||
written without children - "httpRoute:" with the keys below it deleted - is
|
||||
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
|
||||
pointer. An empty block means "chart defaults apply", and all three defaults
|
||||
here are empty or false, so the whole block collapses to "no HTTPRoute".
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
|
||||
even though no Ingress is rendered in this mode - the value names the public
|
||||
hostname of this release, which is what both modes need; it is not an Ingress
|
||||
switch. Setting httpRoute.hostnames explicitly is what a migration over a
|
||||
second hostname needs, and it then wins over ingress.domain.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
|
||||
# backend Service - CyberChef is a static bundle served at the root and has
|
||||
# no second path. The port is the Service's port NUMBER and not its name:
|
||||
# backendRefs takes a PortNumber, so unlike the Ingress this reference
|
||||
# cannot be made by name and the 80 below has to track
|
||||
# cyberchef.service.yaml by hand. ci/run-httproute-match-test.sh is what
|
||||
# holds that pair - it fails when a backendRef names a Service, or a port,
|
||||
# the same render does not create.
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port: 80
|
||||
{{- 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 }}
|
||||
@@ -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"
|
||||
},
|
||||
@@ -108,42 +57,13 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/parentRef"
|
||||
}
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the hostname has a single source of truth.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
|
||||
+3
-128
@@ -68,138 +68,13 @@ cyberchef:
|
||||
ephemeralStorage: 10Mi
|
||||
|
||||
ingress:
|
||||
# Set to false to omit the Ingress entirely (the required clusterIssuer value
|
||||
# is then not needed either, because it is only read inside the Ingress
|
||||
# template). Mutually exclusive with httpRoute.enabled below: with both true
|
||||
# the render aborts, naming both values.
|
||||
# Set to false to omit the Ingress entirely (the required domain and
|
||||
# clusterIssuer values are then not needed either, because they are only
|
||||
# read inside the Ingress template).
|
||||
enabled: true
|
||||
clusterIssuer: null
|
||||
# The public hostname of this release. Read by the Ingress AND - when
|
||||
# httpRoute.hostnames is empty - by the HTTPRoute, so it stays the single
|
||||
# source of truth for the hostname in both modes.
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets.
|
||||
#
|
||||
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
|
||||
# annotation channel for controller behaviour, so everything configured here
|
||||
# - external auth in front of an app that has no login of its own, for
|
||||
# instance - is simply gone when httpRoute.enabled is used instead. It is
|
||||
# then the Gateway's business, or that of an implementation-specific policy
|
||||
# object this chart deliberately does not render.
|
||||
annotations: {}
|
||||
|
||||
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
|
||||
# an existing release is unaffected; exactly one of ingress.enabled and
|
||||
# httpRoute.enabled may be true.
|
||||
#
|
||||
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
|
||||
# gatewayClassName, no Certificate and no implementation-specific policy
|
||||
# object. Class and certificate belong to the Gateway and therefore to the
|
||||
# platform, which is what keeps this chart independent of whether the cluster
|
||||
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
|
||||
#
|
||||
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
|
||||
# cannot detect them (.Capabilities.APIVersions is unreliable at template
|
||||
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
|
||||
httpRoute:
|
||||
enabled: false
|
||||
# The Gateway(s) this route attaches to - a platform object, usually in
|
||||
# another namespace, which must allow this namespace in its listener's
|
||||
# allowedRoutes. One entry per parent, the fields are the Gateway API's
|
||||
# ParentReference (name, and optionally namespace, sectionName, port, group,
|
||||
# kind), passed through unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
# Empty means [ingress.domain], so the hostname has one source of truth. Set
|
||||
# explicitly only to publish under a different or an additional name - which
|
||||
# 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: []
|
||||
@@ -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: 3.1.1+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.
|
||||
|
||||
+15
-207
@@ -10,16 +10,6 @@
|
||||
{{- $shared := include "freesailarr.sharedIngressApps" . | fromJsonArray -}}
|
||||
{{- $sharedNames := list -}}
|
||||
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- $published := include "freesailarr.published" . -}}
|
||||
{{- /* NOTHING IN CI READS THIS FILE, so every claim below is unverified by any
|
||||
pipeline stage. `helm template` does not emit NOTES.txt at all, and no
|
||||
workflow or script under ci/ reads it (measured for Gitea #118: the
|
||||
three hits for "NOTES" under ci/ are comments in fixtures, not reads).
|
||||
The render tests therefore cannot catch a statement here that has gone
|
||||
stale, and the install-test never sees it either — it is prose checked
|
||||
by review only. Treat an edit to it with the same care as the templates
|
||||
it describes, because nothing else will. */ -}}
|
||||
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} containers in ONE pod, sharing gluetun's network namespace.
|
||||
|
||||
@@ -52,53 +42,9 @@ SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW
|
||||
fail, in a way that looks like an application bug. Set it to the cluster pod
|
||||
and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s.
|
||||
{{- end }}
|
||||
{{- /* The servarr apps that are published through a routing object AND have no
|
||||
trusted proxy network set - the one combination in which the empty
|
||||
default actually costs something. Gated on freesailarr.published plus
|
||||
membership in freesailarr.ingressApps, the same pair the ACCESS section
|
||||
below uses, so it holds in Gateway mode too: reading ingress.enabled
|
||||
directly would silence this warning in exactly the mode whose routes
|
||||
still publish the apps. */}}
|
||||
{{- $noTrust := list }}
|
||||
{{- if $published }}
|
||||
{{- range $a := $ingressApps }}
|
||||
{{- if and (has $a.name (list "prowlarr" "radarr" "sonarr")) (not (index $.Values $a.name).trustedNetworks) }}
|
||||
{{- $noTrust = append $noTrust $a.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if $noTrust }}
|
||||
|
||||
FORWARDED HEADERS ARE NOT TRUSTED — {{ join ", " $noTrust }}
|
||||
These apps are published through a routing object and their trustedNetworks
|
||||
value is empty, which is the chart's default. They therefore trust no proxy:
|
||||
the client address each one logs and displays is your ingress controller's
|
||||
pod IP rather than the real client, and each sees the scheme as http even
|
||||
where TLS is terminated in front of it. If an app's authentication is set to
|
||||
"Disabled for Local Addresses", a proxied request no longer counts as local
|
||||
and is asked to authenticate.
|
||||
|
||||
This is not a regression introduced by your values. Upstream removed the
|
||||
hard-coded private networks (prowlarr 2.6.3, radarr 6.4.3; sonarr before
|
||||
4.0.19) because trusting X-Forwarded-For from any private network is a
|
||||
spoofing vector, and this chart follows that default rather than reinstating
|
||||
it behind your back.
|
||||
|
||||
To honour the headers, set the network your ingress controller's pods sit in
|
||||
— the chart cannot know it, as it is a property of the cluster:
|
||||
|
||||
prowlarr.trustedNetworks: "10.42.0.0/16" # e.g. the k3s pod CIDR
|
||||
|
||||
Leaving it empty is a valid choice; it is only not a silent one.
|
||||
{{- end }}
|
||||
|
||||
ACCESS
|
||||
{{- if and $published $ingressApps }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
{{- else }}
|
||||
GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- end }}
|
||||
{{- if and .Values.ingress.enabled $ingressApps }}
|
||||
{{- range $shared }}
|
||||
{{ .name }}: https://{{ $sharedHost }}{{ if ne .path "/" }}{{ .path }}{{ end }}
|
||||
{{- end }}
|
||||
@@ -110,8 +56,7 @@ ACCESS
|
||||
{{- end }}
|
||||
{{- if $shared }}
|
||||
|
||||
These apps share one host{{ if .Values.ingress.enabled }}, one Ingress and one certificate{{ else }} and one HTTPRoute ({{ $name }}-httproute), one
|
||||
rule each{{ end }}.
|
||||
These apps share one host, one Ingress and one certificate.
|
||||
{{- if has "bazarr" $sharedNames }}
|
||||
|
||||
BAZARR NEEDS ONE MANUAL STEP UNDER A SUB-PATH
|
||||
@@ -128,45 +73,14 @@ ACCESS
|
||||
{{- if has "qbittorrent" $sharedNames }}
|
||||
|
||||
QBITTORRENT SITS BEHIND A PREFIX-STRIPPING REWRITE
|
||||
It has no base path of its own, so the prefix has to be cut off before the
|
||||
request reaches it.
|
||||
{{- if .Values.ingress.enabled }} Under an Ingress that costs a SECOND object on the
|
||||
same host, because nginx applies rewrite-target per object and the other
|
||||
apps must not be rewritten.
|
||||
{{- else }} In Gateway mode that second object is gone: a
|
||||
URLRewrite filter belongs to a RULE, so qBittorrent is a rule of
|
||||
{{ $name }}-httproute with the filter on it and its neighbours are
|
||||
untouched by construction — no extra object, no regex path.
|
||||
{{- end }}
|
||||
|
||||
OPEN IT WITH THE TRAILING SLASH:
|
||||
|
||||
https://{{ $sharedHost }}{{ include "freesailarr.appPath" (dict "root" $ "app" "qbittorrent") }}/
|
||||
|
||||
{{ if .Values.ingress.enabled }} Without the slash the prefix is stripped to "/", qBittorrent answers 200,
|
||||
and every RELATIVE link in that page resolves against the ROOT of this
|
||||
host — which is {{ if has "seerr" $sharedNames }}seerr{{ else }}whatever serves "/" here{{ end }}, not qBittorrent. The login form then looks
|
||||
unstyled and does nothing while another application answers its requests.
|
||||
This chart does NOT render that redirect in Ingress mode: nginx offers only
|
||||
a configuration-snippet annotation for it, and snippets are ignored unless
|
||||
the controller runs with allow-snippet-annotations=true, which has been off
|
||||
by default since ingress-nginx 1.9 and means accepting annotation risk
|
||||
level "Critical". The working snippet, and its price, are in values.yaml at
|
||||
qbittorrent.ingress (Gitea #118).
|
||||
|
||||
{{ else }} The bare, slashless path is redirected 301 to the slashed form by a rule of
|
||||
{{ $name }}-httproute — an Exact path match, which the Gateway API ranks
|
||||
above every prefix match, so it wins wherever it sits in the list. Without
|
||||
it qBittorrent would answer 200 under a URL whose relative links resolve to
|
||||
the root of this host, which is a different application (Gitea #118). This
|
||||
is the one thing the Gateway mode does here that the Ingress mode cannot.
|
||||
|
||||
{{ end }} Two further things look exactly like a login failure and send you
|
||||
looking in the wrong place: its session cookie is marked "Secure" (over
|
||||
plain HTTP the browser never sends it back and every click bounces to the
|
||||
login form), and it bans the client IP for a while after a few failed
|
||||
attempts. If it does not work at all, set qbittorrent.ingress.host and it
|
||||
is back on a host of its own - no chart change needed.
|
||||
It has no base path of its own, so it gets a second Ingress object on the
|
||||
same host whose rewrite cuts the prefix off. Two things there look exactly
|
||||
like a login failure and send you looking in the wrong place: its session
|
||||
cookie is marked "Secure" (over plain HTTP the browser never sends it back
|
||||
and every click bounces to the login form), and it bans the client IP for a
|
||||
while after a few failed attempts. If it does not work at all, set
|
||||
qbittorrent.ingress.host and it is back on a host of its own - no chart
|
||||
change needed.
|
||||
{{- end }}
|
||||
{{- if not (has "seerr" $sharedNames) }}
|
||||
|
||||
@@ -176,48 +90,12 @@ ACCESS
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Every app listed with a host of its own has its OWN Ingress and its own TLS
|
||||
secret. While cert-manager is still issuing, the browser warns:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get certificate
|
||||
{{- else }}
|
||||
Every app listed with a host of its own has its OWN HTTPRoute
|
||||
({{ $name }}-<app>-httproute).
|
||||
|
||||
THREE THINGS BELONG TO THE CLUSTER AND NOT TO THIS CHART IN THIS MODE, and if
|
||||
any is missing the addresses above do not answer:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. Every route of this release
|
||||
attaches to that same parent. A rejected attachment shows up only in a
|
||||
route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is a working attachment. An empty status means no
|
||||
controller has looked at the route at all.
|
||||
3. The TLS certificates for these hostnames, on the Gateway's listener.
|
||||
ingress.clusterIssuer is NOT used in this mode and is not required, and
|
||||
neither are ingress.className or the per-app overrides of either: the
|
||||
chart renders no Certificate and no class, so there are no
|
||||
tls-{{ $name }}-* secrets and nothing for cert-manager to do.
|
||||
|
||||
ingress.annotations HAS NO COUNTERPART HERE, per-app overrides included: the
|
||||
Gateway API has no annotation channel. The chart needs nothing from it —
|
||||
qBittorrent's rewrite is a filter in this mode — but an annotation you added
|
||||
yourself is gone without a word. A body-size cap for qBittorrent's
|
||||
torrent-file uploads is the likely one; set it at the Gateway or through its
|
||||
implementation's own policy object.
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
Nothing is published{{ if and (not .Values.ingress.enabled) (not $httpRoute.enabled) }} — ingress.enabled and httpRoute.enabled are both false{{ else if not $ingressApps }} — no enabled app carries <app>.ingress.enabled: true{{ end }}. Reach an app through its own service:
|
||||
No Ingress is rendered{{ if not .Values.ingress.enabled }} (ingress.enabled is false){{ end }}. Reach an app through its own service:
|
||||
{{- range $portApps }}
|
||||
|
||||
kubectl -n {{ $.Release.Namespace }} port-forward svc/{{ $name }}-{{ .name }}-service {{ .port }}:{{ .port }}
|
||||
@@ -227,8 +105,7 @@ ACCESS
|
||||
|
||||
qBittorrent's WebUI rejects a Host header whose port differs from its own
|
||||
({{ "8080" }}) with a plain 401. A port-forward on a different local port therefore
|
||||
looks like a login failure — forward 8080:8080, or go through the published
|
||||
address above.
|
||||
looks like a login failure — forward 8080:8080, or go through the ingress.
|
||||
{{- if .Values.qbittorrent.savePath }}
|
||||
|
||||
QBITTORRENT DOWNLOADS GO TO {{ .Values.qbittorrent.savePath }}
|
||||
@@ -281,8 +158,8 @@ MEDIATHEKARR: TWO PORTS, AND THE SABNZBD API IS NOT AT /api
|
||||
back as the SABnzbd complete_dir. Any deviation — a remote path mapping, a
|
||||
different mount — imports NOTHING, without an error.
|
||||
|
||||
The UI (setup wizard and download queue) is not published{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than on the
|
||||
host you gave it, {{ .Values.mediathekarr.ingress.host }}{{ end }}:
|
||||
The UI (setup wizard and download queue) has no ingress{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than the one
|
||||
you gave it on {{ .Values.mediathekarr.ingress.host }}{{ end }}:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $name }}-mediathekarr-service 5007:5007
|
||||
{{- if $gate.enabled }}
|
||||
@@ -409,7 +286,7 @@ THE MEDIA CLAIMS ARE YOURS, THE CONFIG VOLUMES ARE THE CHART'S
|
||||
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0. PVCs, secrets,
|
||||
services{{ if .Values.ingress.enabled }} and ingresses{{ else if $httpRoute.enabled }} and HTTPRoutes{{ end }} stay in place; set it back and upgrade to bring the
|
||||
services{{ if .Values.ingress.enabled }} and ingresses{{ end }} stay in place; set it back and upgrade to bring the
|
||||
stack up again.
|
||||
{{- else if ne $replicas "1" }}
|
||||
|
||||
@@ -418,72 +295,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 }}
|
||||
@@ -205,49 +205,8 @@ in the ingress, which addresses a service port by name.
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Is this release published through a routing object at all? Returns the string
|
||||
"true" or the empty string (falsy in a template "if").
|
||||
|
||||
Input: the root context.
|
||||
|
||||
ONE PLACE, BECAUSE THE WRONG ANSWER IS INVISIBLE (Gitea #114)
|
||||
|
||||
ingress.enabled is the chart's master switch, and until the Gateway-API mode
|
||||
existed it was also the answer to "is anything published". It stopped being
|
||||
that answer in exactly one configuration - and that configuration is the whole
|
||||
point of the new mode: httpRoute.enabled: true forces ingress.enabled: false
|
||||
(the two are mutually exclusive, the render aborts otherwise), while the apps
|
||||
ARE published, now through HTTPRoutes.
|
||||
|
||||
The consumer that makes this load-bearing is NOT the routing templates - those
|
||||
know their own mode - but freesailarr.sharedIngressApps below, which is also
|
||||
read by the <APP>__SERVER__URLBASE environment variables (see
|
||||
freesailarr.urlBase). Read from ingress.enabled alone, that list would come out
|
||||
EMPTY in Gateway mode, so Radarr, Sonarr and Prowlarr would be told no base
|
||||
path while their routes serve them under /radarr, /sonarr and /prowlarr. Each
|
||||
app would then answer at the sub-path with a page whose assets and API calls
|
||||
are linked at the host root, i.e. a broken UI behind a Ready pod - the same
|
||||
silent class as sure's TLS flags, found in the same sweep. The duplicate-path
|
||||
and root-only guards in that helper would also have gone unenforced in Gateway
|
||||
mode, for the same reason.
|
||||
|
||||
With httpRoute.enabled at its default of false this expression IS
|
||||
ingress.enabled, which is why adding it changes nothing for an existing
|
||||
release: `helm template` with unchanged values renders byte-identically.
|
||||
*/}}
|
||||
{{- define "freesailarr.published" -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if or .Values.ingress.enabled $httpRoute.enabled -}}true{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The apps that are published through a routing object: the enabled port apps
|
||||
whose values carry an ingress block with enabled: true. Used by BOTH routing
|
||||
templates - the per-app <app>.ingress.enabled toggle selects which apps are
|
||||
exposed in Ingress mode and in Gateway mode alike, and only the Ingress-only
|
||||
fields of that block (clusterIssuer, className, annotations) have no meaning in
|
||||
the second one. The name is historical; renaming it would churn every call site
|
||||
for no behaviour.
|
||||
The apps that render an Ingress: the enabled port apps whose values carry an
|
||||
ingress block with enabled: true. Used by the ingress template.
|
||||
|
||||
{{- $apps := include "freesailarr.ingressApps" . | fromJsonArray }}
|
||||
*/}}
|
||||
@@ -293,19 +252,14 @@ The apps served under the shared domain (ingress.host), as a JSON array of
|
||||
is deterministic.
|
||||
|
||||
The shared-domain mode is off unless ingress.host is set; then it covers the
|
||||
apps whose routing object renders at all (freesailarr.ingressApps, i.e. the
|
||||
enabled port apps with ingress.enabled) and which do not carry a host of their
|
||||
own. A per-app host always wins and keeps rendering its own object exactly as
|
||||
before, which is what makes mixed operation fall out for free.
|
||||
apps whose ingress renders at all (freesailarr.ingressApps, i.e. the enabled
|
||||
port apps with ingress.enabled) and which do not carry a host of their own. A
|
||||
per-app host always wins and keeps rendering its own object exactly as before,
|
||||
which is what makes mixed operation fall out for free.
|
||||
|
||||
Gated on freesailarr.published and NOT on ingress.enabled (Gitea #114): this
|
||||
list is read by the UrlBase env vars too, and in Gateway mode ingress.enabled
|
||||
is false by construction while the apps are published all the same. See that
|
||||
helper for what reading the switch directly would have broken silently.
|
||||
|
||||
Two guards live here rather than in the routing templates, so that every
|
||||
consumer - the Ingress objects, the HTTPRoutes, the UrlBase env vars and
|
||||
NOTES.txt - sees the same validated list:
|
||||
Two guards live here rather than in the ingress template, so that every
|
||||
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
|
||||
same validated list:
|
||||
|
||||
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
|
||||
support (upstream request open since May 2022) and MediathekArr's UI links
|
||||
@@ -323,7 +277,7 @@ NOTES.txt - sees the same validated list:
|
||||
{{- $root := . -}}
|
||||
{{- $apps := list -}}
|
||||
{{- $host := $root.Values.ingress.host | default "" -}}
|
||||
{{- if and (include "freesailarr.published" $root) $host -}}
|
||||
{{- if and $root.Values.ingress.enabled $host -}}
|
||||
{{- $seen := dict -}}
|
||||
{{- range $app := include "freesailarr.ingressApps" $root | fromJsonArray -}}
|
||||
{{- $config := (index $root.Values $app.name).ingress -}}
|
||||
@@ -366,16 +320,9 @@ base path from this environment variable (schema
|
||||
<APP>__CONFIGNAMESPACE__CONFIGITEM, https://wiki.servarr.com/sonarr/environment-variables),
|
||||
so the chart can configure them itself as soon as they render under a sub-path.
|
||||
|
||||
Mode-independent by construction (Gitea #114): the sub-path an app is served
|
||||
under is the same in Ingress and in Gateway mode, so this variable follows
|
||||
freesailarr.sharedIngressApps and not the routing object. That is only true
|
||||
because that helper asks freesailarr.published rather than ingress.enabled -
|
||||
reading the switch directly would have emptied this variable in Gateway mode
|
||||
while the route still served the app under its prefix.
|
||||
|
||||
Empty - i.e. the chart sets nothing - in three cases:
|
||||
|
||||
- the app is not served under the shared host, or is served at "/";
|
||||
- the app does not render under the shared host, or renders at "/";
|
||||
- the app's own env block already carries the variable. The env blocks are
|
||||
documented as additive; emitting a second entry with the same name would
|
||||
break that promise, and two identically named variables in one container
|
||||
@@ -406,46 +353,6 @@ Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
|
||||
{{- $path -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The value for a servarr app's <APP>__SERVER__TRUSTEDNETWORKS variable, or the
|
||||
empty string when the chart must not set it. Same env schema as the UrlBase
|
||||
variable above (<APP>__CONFIGNAMESPACE__CONFIGITEM): verified for all three
|
||||
apps at the tags this chart pins, where Bootstrap.cs binds
|
||||
services.Configure<ServerOptions>(config.GetSection("<App>:Server")) next to a
|
||||
plain .AddEnvironmentVariables().
|
||||
|
||||
The variable is emitted ONLY when the value is non-empty. An empty
|
||||
TRUSTEDNETWORKS would be indistinguishable from an unset one for the app -
|
||||
upstream reads string.Empty either way - but not for a reader of the manifest,
|
||||
to whom it would look like a setting that had been taken care of.
|
||||
|
||||
Empty - i.e. the chart sets nothing - in two cases:
|
||||
|
||||
- the app's trustedNetworks value is empty, which is the DEFAULT. The apps
|
||||
then trust no proxy at all; see the value's comment in values.yaml for what
|
||||
that costs and why it is not a preserved behaviour;
|
||||
- the app's own env block already carries the variable, for the same reason
|
||||
the UrlBase helper above stays out of that case: the env blocks are
|
||||
additive, and two identically named variables in one container are not a
|
||||
defined state to build on.
|
||||
|
||||
Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
|
||||
*/}}
|
||||
{{- define "freesailarr.trustedNetworks" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $app := .app -}}
|
||||
{{- $var := printf "%s__SERVER__TRUSTEDNETWORKS" (upper $app) -}}
|
||||
{{- $value := (index $root.Values $app).trustedNetworks | default "" -}}
|
||||
{{- if $value -}}
|
||||
{{- range $entry := (index $root.Values $app).env -}}
|
||||
{{- if eq (toString $entry.name) $var -}}
|
||||
{{- $value = "" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $value -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app
|
||||
containers (ingress traffic and the kubelet probes both arrive on the pod IP)
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
{{/*
|
||||
The Gateway-API alternative to freesailarr.ingress.yaml (Gitea #114). Form and
|
||||
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
|
||||
Gateway API".
|
||||
|
||||
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate, and no implementation-specific policy object - that subset looks
|
||||
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
|
||||
the whole reason the cluster's implementation can be swapped without touching
|
||||
this chart. Class and certificate therefore belong to the PLATFORM, on the
|
||||
Gateway and its listeners. ingress.clusterIssuer, ingress.className,
|
||||
ingress.annotations and their per-app overrides are all inert in this mode.
|
||||
|
||||
THE SAME TWO SHAPES AS THE INGRESS, AND THEY STILL MIX
|
||||
|
||||
1. One host per app (the default): every published app gets its own
|
||||
HTTPRoute with its own hostname, pointing at its own app's Service.
|
||||
2. One shared domain with sub-paths (ingress.host): the apps that carry no
|
||||
host of their own move into ONE route with one rule each.
|
||||
|
||||
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames
|
||||
|
||||
It is the documented exception on that point (README, guideline 4): the chart
|
||||
bundles ten independently exposed apps, so there is no single hostname to put
|
||||
in a chart-global list - a hostnames entry could not say which app it belonged
|
||||
to. The hostnames therefore come from the values that already hold them,
|
||||
ingress.host and <app>.ingress.host, which keeps the single source of truth the
|
||||
convention is actually about. Those two are not Ingress switches: they name the
|
||||
public addresses of this release, and both modes read them.
|
||||
|
||||
Consequence for a migration: the "second hostname" the convention offers
|
||||
elsewhere is done here by changing <app>.ingress.host, which in Gateway mode
|
||||
feeds only the route - there is no Ingress rendered to disagree with it.
|
||||
|
||||
WHERE THE GATEWAY API IS BETTER THAN WHAT IT REPLACES, AND IT IS NOT A
|
||||
TRANSLATION
|
||||
|
||||
qBittorrent has no base-path setting: it serves its WebUI at the root and the
|
||||
proxy has to strip the prefix. nginx applies rewrite-target per INGRESS OBJECT
|
||||
and not per path, and the other apps must NOT be rewritten - their UrlBase
|
||||
contains the prefix, so they expect the full path. That is why
|
||||
freesailarr.ingress.yaml splits qBittorrent into a SECOND Ingress object on the
|
||||
same host, which then has to
|
||||
|
||||
- repeat the same TLS secret name as the main object, so that neither object
|
||||
depends on an assumption about whether nginx applies the other one's TLS
|
||||
configuration to its paths,
|
||||
- OMIT the cert-manager annotation so only one Certificate is requested for
|
||||
that one secret - except when no main object renders at all, which is a
|
||||
conditional inside the annotation block, and
|
||||
- match on an ImplementationSpecific path with a regex
|
||||
("<path>(/|$)(.*)") whose capture group 2 the rewrite refers to.
|
||||
|
||||
The Gateway API's URLRewrite filter is per RULE. All of that disappears: one
|
||||
route, one rule per app, and qBittorrent's rule carries the filter while its
|
||||
neighbours do not. The path match goes back to a plain PathPrefix, there is no
|
||||
second object, no duplicated TLS block, no conditional annotation and no
|
||||
regex. The convention's own summary says rewrite-target "has a real
|
||||
equivalent"; in this chart the equivalent is strictly less machinery than the
|
||||
original, and that is the reason this chart was worth converting rather than
|
||||
merely translating.
|
||||
|
||||
qBittorrent is the one app that contributes a SECOND rule, and it is not part
|
||||
of the rewrite: a 301 from the bare, slashless prefix to the slashed form
|
||||
(Gitea #118). Without it qBittorrent answers 200 under a URL whose relative
|
||||
links resolve to the host root, which on the shared host is a DIFFERENT
|
||||
application. In Ingress mode the chart does not render that redirect - it
|
||||
cannot, as the only mechanism nginx offers for it is a snippet annotation that
|
||||
most clusters refuse (values.yaml, qbittorrent.ingress, has the full price).
|
||||
This is therefore the one place where the Gateway mode is not merely less
|
||||
machinery but closes a gap the Ingress mode leaves open.
|
||||
|
||||
WHAT IS NOT BETTER: ANYTHING THAT WAS AN ANNOTATION AND IS NOT A FILTER. The
|
||||
per-app and global ingress.annotations have no counterpart at all here. This
|
||||
chart sets no body-size annotation of its own, so nothing it NEEDS is lost -
|
||||
but an operator who added one through ingress.annotations (qBittorrent takes
|
||||
torrent file uploads) loses it silently in this mode. NOTES.txt says so.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block this chart dereferences (#99): an override
|
||||
written without children - "httpRoute:" with the keys below it deleted - is
|
||||
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
|
||||
pointer. An empty block means "chart defaults apply", and both defaults are
|
||||
empty or false, so the whole block collapses to "no HTTPRoute".
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingresses and the HTTPRoutes would publish the same Services under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. ingress.enabled is this chart's MASTER switch and defaults to true, so turning the Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over different hostnames (change ingress.host or <app>.ingress.host), never over parallel operation of both modes." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- $sharedHost := .Values.ingress.host | default "" }}
|
||||
{{- /* The same validated list the Ingress uses, and the same list the UrlBase
|
||||
env vars read: it is gated on freesailarr.published, so it is populated
|
||||
in this mode as well. Its two guards - seerr/mediathekarr only at "/",
|
||||
no duplicate paths on the shared host - therefore apply here unchanged,
|
||||
which is the whole reason they live in the helper. */}}
|
||||
{{- $shared := include "freesailarr.sharedIngressApps" $ | fromJsonArray }}
|
||||
{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }}
|
||||
{{- $config := (index $.Values $app.name).ingress }}
|
||||
{{- /* Skip the apps that live on the shared domain; they are one rule each in
|
||||
the single route below. */}}
|
||||
{{- if or (not $sharedHost) ($config.host | default "") }}
|
||||
{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true (or set the shared ingress.host to serve the app under a sub-path instead). The value is the app's public hostname and is read in Gateway mode as well - it is not an Ingress switch." $app.name $app.name) $config.host }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-httproute
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
- {{ $host | quote }}
|
||||
rules:
|
||||
{{- /* The Ingress counterpart is `path: / , pathType: Prefix` on this
|
||||
app's own Service. An app on a host of its own is served at the
|
||||
root, so no rewrite is involved in either mode - that question only
|
||||
arises under the shared host below.
|
||||
|
||||
The port is the Service's port NUMBER and not its name (the Ingress
|
||||
addresses it by name, which is the app name): backendRefs takes a
|
||||
PortNumber, so this reference cannot be made by name and the number
|
||||
has to track freesailarr.service.yaml. Both come from
|
||||
freesailarr.portApps, which is also what the Service renders from,
|
||||
so they cannot drift - and
|
||||
ci/run-httproute-match-test.sh fails when a backendRef names a
|
||||
Service, or a port, the same render does not create. */}}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
|
||||
port: {{ int $app.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if $shared }}
|
||||
{{/*
|
||||
The shared-domain route: ONE object for every app under ingress.host, one rule
|
||||
each, rendered only when at least one app actually contributes a path - so
|
||||
switching every app off while leaving ingress.host set produces no object
|
||||
rather than an empty one without rules.
|
||||
|
||||
qBittorrent is in here with the others, which is the difference to the Ingress:
|
||||
a filter in the Gateway API belongs to a RULE, so it contributes TWO rules of
|
||||
its own and the apps around it stay untouched by construction - a PathPrefix
|
||||
rule with the URLRewrite filter that strips the prefix, and an Exact rule on
|
||||
the bare, slashless prefix that redirects 301 to the slashed form (Gitea #118;
|
||||
the long reasoning is on that rule). No second object, no repeated TLS secret,
|
||||
no conditional cert-manager annotation, no regex path - see the header.
|
||||
|
||||
The rules come sorted by path length descending, as the Ingress paths do, and
|
||||
qBittorrent's redirect rule follows the rule it corrects. Neither placement
|
||||
carries meaning: the Gateway API specifies matching precedence by criteria
|
||||
before order - an Exact match outranks every prefix match, and among prefix
|
||||
matches the longest wins - and list order is consulted only when all of those
|
||||
tie. So the ordering here is about a deterministic, reviewable rendering, not
|
||||
about matching behaviour. That it is ALSO the order nginx would need is a
|
||||
coincidence of this chart's history and not a property to rely on.
|
||||
*/}}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
- {{ $sharedHost | quote }}
|
||||
rules:
|
||||
{{- range $app := $shared }}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: {{ $app.path | quote }}
|
||||
{{- if eq $app.name "qbittorrent" }}
|
||||
{{- /* qBittorrent has no base-path setting - it serves its WebUI at the
|
||||
root, so the prefix has to be stripped
|
||||
(https://github.com/qbittorrent/wiki/blob/master/NGINX-Reverse-Proxy-for-Web-UI.md).
|
||||
ReplacePrefixMatch replaces exactly the PathPrefix matched above,
|
||||
which is why the match must be a PathPrefix and not a regex.
|
||||
|
||||
The apps around it must NOT be rewritten: their UrlBase carries
|
||||
the prefix, so they expect the full path. Under an Ingress that
|
||||
made a second object unavoidable, because rewrite-target applies
|
||||
per object; here the filter is scoped to this rule and the others
|
||||
are untouched by construction. */}}
|
||||
filters:
|
||||
- type: URLRewrite
|
||||
urlRewrite:
|
||||
path:
|
||||
type: ReplacePrefixMatch
|
||||
replacePrefixMatch: /
|
||||
{{- end }}
|
||||
backendRefs:
|
||||
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
|
||||
port: {{ int $app.port }}
|
||||
{{- if and (eq $app.name "qbittorrent") (ne $app.path "/") }}
|
||||
{{- /* THE BARE PREFIX, WITHOUT THE TRAILING SLASH (Gitea #118)
|
||||
|
||||
A request for the bare qbittorrent.ingress.path - "/qbittorrent"
|
||||
by default, no trailing slash - is matched by the PathPrefix rule
|
||||
above and the URLRewrite turns it into "/". That is a valid
|
||||
request to qBittorrent, which answers 200 with its login
|
||||
page, so nothing anywhere reports a problem. But the browser's
|
||||
document URL stays prefixless, and a relative link in that page
|
||||
resolves against the PARENT of that path - which is the host root.
|
||||
Every asset, every XHR and every form target therefore goes to "/",
|
||||
and "/" on this shared host is SEERR. The symptom is qBittorrent's
|
||||
login page that is unstyled and does nothing, while the requests it
|
||||
makes are answered by a different application entirely - and seerr
|
||||
answering a qBittorrent request is not a clue anyone follows
|
||||
quickly. One redirect to the slashed form fixes all of it, because
|
||||
relative links then resolve against the slashed prefix.
|
||||
|
||||
PRECEDENCE IS NOT POSITION. This rule is matched by the Gateway API
|
||||
on an Exact path, and the specification ranks "Exact path match"
|
||||
ABOVE "Prefix path match with largest number of characters", with
|
||||
list order consulted only when every earlier criterion ties
|
||||
(gateway-api v1.6.3, apis/v1/httproute_types.go). So this rule wins
|
||||
over the PathPrefix rule above AND over seerr's PathPrefix "/" no
|
||||
matter where it is emitted. The length-descending sort of the rules
|
||||
in this route therefore stays what the header says it is - a
|
||||
deterministic, reviewable rendering - and emitting this rule
|
||||
directly after the one it corrects is a readability choice, not a
|
||||
correctness one.
|
||||
|
||||
301 AND NOT 308, deliberately, and the reason is not the HTTP
|
||||
semantics but which values an implementation is obliged to support.
|
||||
The Gateway API marks 301 and 302 as CORE and everything else -
|
||||
308 included - as EXTENDED. An implementation that does not support
|
||||
the value given here must set the Accepted condition of the ROUTE to
|
||||
status False with reason UnsupportedValue: not of this rule, of the
|
||||
whole object. On the shared host that is one route carrying every
|
||||
app under it, so a 308 that one implementation declines takes
|
||||
prowlarr, radarr, sonarr and seerr down with qBittorrent - and it
|
||||
does so on a route that is schema-valid, in a status field no
|
||||
controller writes in CI. It would also contradict decision 1 of
|
||||
Gitea #114, which is that this chart renders only the subset
|
||||
that looks identical on nginx-gateway-fabric, Cilium, Envoy
|
||||
Gateway and Istio, so that the cluster's implementation can be
|
||||
swapped without touching the chart.
|
||||
|
||||
What 308 would buy is method and body preservation, and this rule
|
||||
does not need it: it fires on exactly one path, the prefixless entry
|
||||
to the WebUI, which a browser reaches with GET. qBittorrent's API
|
||||
lives under "<path>/api/..." - slashed, longer, and therefore
|
||||
served by the PathPrefix rule above, which this rule never sees. A
|
||||
POST to the API is not redirected at all, so there is no body to
|
||||
lose.
|
||||
|
||||
statusCode MUST BE WRITTEN OUT: it defaults to 302 in the API, and
|
||||
a temporary redirect here would be re-fetched by every client on
|
||||
every visit forever, for a prefix that is a permanent property of
|
||||
this release's layout.
|
||||
|
||||
THE ONE ASYMMETRY TO THE nginx SNIPPET (values.yaml, qbittorrent):
|
||||
HTTPRequestRedirectFilter has exactly five fields - scheme,
|
||||
hostname, path, port, statusCode - and says nothing about the query
|
||||
string, while ReplaceFullPath is specified as replacing the full
|
||||
PATH. The nginx remedy in Ingress mode appends "$is_args$args" and
|
||||
so carries the query over explicitly; here the query's fate is left
|
||||
to the implementation and is not something this chart can state.
|
||||
It costs nothing on the path this rule actually serves - the UI
|
||||
entry point takes no query parameters - but it is a real difference
|
||||
and not a translation. */}}
|
||||
- matches:
|
||||
- path:
|
||||
type: Exact
|
||||
value: {{ $app.path | quote }}
|
||||
filters:
|
||||
- type: RequestRedirect
|
||||
requestRedirect:
|
||||
path:
|
||||
type: ReplaceFullPath
|
||||
replaceFullPath: {{ printf "%s/" $app.path | quote }}
|
||||
statusCode: 301
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- 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 }}
|
||||
@@ -279,14 +279,6 @@ spec:
|
||||
- name: PROWLARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* Proxy networks whose forwarded headers prowlarr honours.
|
||||
Emitted only when set - empty is the default and means no
|
||||
proxy is trusted. See the freesailarr.trustedNetworks
|
||||
helper and the value's comment in values.yaml. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "prowlarr")) }}
|
||||
- name: PROWLARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 9696
|
||||
@@ -356,11 +348,6 @@ spec:
|
||||
- name: RADARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "radarr")) }}
|
||||
- name: RADARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 7878
|
||||
@@ -437,13 +424,6 @@ spec:
|
||||
- name: SONARR__SERVER__URLBASE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. Sonarr did
|
||||
not lose the trust with this chart version - 4.0.19 was
|
||||
already in the empty state. */}}
|
||||
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "sonarr")) }}
|
||||
- name: SONARR__SERVER__TRUSTEDNETWORKS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 8989
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -178,11 +127,11 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Master switch; false suppresses every app ingress regardless of the per-app toggles. Mutually exclusive with httpRoute.enabled - the render aborts when both are true, and since this defaults to true it has to be switched off explicitly to use the Gateway mode.",
|
||||
"description": "Master switch; false suppresses every app ingress regardless of the per-app toggles.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"description": "Shared domain for the sub-path mode. Empty or null means the default: every app on a host of its own. Read by BOTH routing modes - it names the public address of the stack and is not an Ingress switch. This chart has no ingress.domain, so this value and the per-app <app>.ingress.host are also where the HTTPRoutes take their hostnames from.",
|
||||
"description": "Shared domain for the sub-path mode. Empty or null means the default: every app on a host of its own.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -201,28 +150,11 @@
|
||||
]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. No counterpart in Gateway mode, per-app overrides included: the Gateway API has no annotation channel, so anything set here is gone when httpRoute.enabled is used instead. The chart needs nothing from it - qBittorrent's prefix-stripping rewrite has a per-rule URLRewrite filter as its equivalent there - but an annotation you added yourself is lost silently.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, and ingress.enabled defaults to true in this chart. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. Both ingress shapes work unchanged, and the sub-path shape gets simpler: a URLRewrite filter is per rule, so qBittorrent needs no second routing object. There is deliberately no hostnames key - this chart has no ingress.domain either, and a chart-global hostname list could not say which of the ten apps it belonged to, so the hostnames stay in ingress.host and <app>.ingress.host. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) every route of this release attaches to. Required while httpRoute.enabled is true, enforced in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/parentRef"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gluetun": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -380,9 +312,6 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -449,9 +378,6 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -521,9 +447,6 @@
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"$ref": "#/definitions/trustedNetworks"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
@@ -685,13 +608,6 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"image": {
|
||||
"description": "Every image in this chart is pinned here, gluetun included; no tag comes from appVersion. This is also what puts them all under Renovate's helm-values manager, which reads exactly these two keys.",
|
||||
"type": "object",
|
||||
@@ -750,13 +666,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trustedNetworks": {
|
||||
"description": "Comma-separated CIDRs whose X-Forwarded-For/-Proto/-Host headers this servarr app honours, rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty. Empty is the default and means no proxy is trusted - the app then reports the ingress controller's pod IP as the client and sees the scheme as http behind TLS termination. Kept null-safe deliberately: an erased value is the same permissive-to-nobody state as the default, so it must not fail the render.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"appStorage": {
|
||||
"description": "Size of this app's config volumeClaimTemplate. Not null-safe on purpose: an erased size yields an invalid claim template and fails loudly.",
|
||||
"type": "object",
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user